288 行
10 KiB
TypeScript
288 行
10 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import { creatableImportRows,
|
|
buildNextEditedRow,
|
|
canEditResultRow,
|
|
canEditReviewRow,
|
|
canRetryResultRow,
|
|
hasExactSourceRows,
|
|
initialisePreviewRows,
|
|
mergeImportResults,
|
|
mergeValidatedImportRow,
|
|
mergeValidatedImportRows,
|
|
processableImportRows,
|
|
replaceImportRow,
|
|
resultRepairMode,
|
|
resultRowMessages,
|
|
resultSummaryCounts,
|
|
retryImportRow,
|
|
reviewSummaryCounts } from '@/lib/postImportSession'
|
|
import { buildPostImportRow } from '@/test/postImportFactories'
|
|
|
|
describe ('post import row state', () => {
|
|
it ('separates processable existing rows from creatable rows', () => {
|
|
const ready = buildPostImportRow ({ sourceRow: 1 })
|
|
const existing = buildPostImportRow ({
|
|
sourceRow: 2,
|
|
skipReason: 'existing',
|
|
existingPostId: 20 })
|
|
const invalid = buildPostImportRow ({
|
|
sourceRow: 3,
|
|
status: 'error',
|
|
validationErrors: { url: ['invalid'] } })
|
|
const created = buildPostImportRow ({
|
|
sourceRow: 4,
|
|
importStatus: 'created',
|
|
createdPostId: 40 })
|
|
const rows = [ready, existing, invalid, created]
|
|
|
|
expect (processableImportRows (rows)).toEqual ([ready, existing])
|
|
expect (creatableImportRows (rows)).toEqual ([ready])
|
|
expect (reviewSummaryCounts (rows)).toEqual ({
|
|
total: 4,
|
|
submittable: 1,
|
|
skipPlanned: 1 })
|
|
})
|
|
|
|
it ('preserves terminal rows while merging validation results', () => {
|
|
const created = buildPostImportRow ({
|
|
sourceRow: 1,
|
|
importStatus: 'created',
|
|
createdPostId: 10,
|
|
attributes: { title: 'created title' } })
|
|
const pending = buildPostImportRow ({
|
|
sourceRow: 2,
|
|
fieldWarnings: { title: ['old warning'] },
|
|
provenance: { title: 'manual' },
|
|
attributes: { title: 'manual title' } })
|
|
const validated = [
|
|
buildPostImportRow ({ sourceRow: 1, attributes: { title: 'changed' } }),
|
|
buildPostImportRow ({
|
|
sourceRow: 2,
|
|
fieldWarnings: { title: ['fetch warning'], tags: ['tag warning'] },
|
|
provenance: { title: 'manual' },
|
|
attributes: { title: 'manual title' } })]
|
|
|
|
const result = mergeValidatedImportRows ([created, pending], validated)
|
|
|
|
expect (result[0]).toBe (created)
|
|
expect (result[1]?.fieldWarnings).toEqual ({ tags: ['tag warning'] })
|
|
})
|
|
|
|
it ('updates the reset snapshot only after metadata URL changes', () => {
|
|
const current = buildPostImportRow ({
|
|
attributes: { title: 'manual title' },
|
|
metadataUrl: 'https://example.com/old' })
|
|
const validated = buildPostImportRow ({
|
|
attributes: { title: 'new metadata title' },
|
|
metadataUrl: 'https://example.com/new',
|
|
fieldWarnings: { title: ['warning'] },
|
|
baseWarnings: ['base warning'] })
|
|
|
|
const result = mergeValidatedImportRows ([current], [validated])[0]
|
|
|
|
expect (result?.resetSnapshot).toMatchObject ({
|
|
attributes: { title: 'new metadata title' },
|
|
fieldWarnings: { title: ['warning'] },
|
|
baseWarnings: ['base warning'],
|
|
metadataUrl: 'https://example.com/new' })
|
|
})
|
|
|
|
it ('merges result states and clears incompatible post identifiers', () => {
|
|
const created = mergeImportResults ([buildPostImportRow ({
|
|
skipReason: 'existing',
|
|
existingPostId: 2,
|
|
recoverable: true,
|
|
importStatus: 'pending' })], [{
|
|
sourceRow: 1,
|
|
status: 'created',
|
|
post: { id: 3 } }])[0]
|
|
const skipped = mergeImportResults ([buildPostImportRow ({
|
|
createdPostId: 3,
|
|
importStatus: 'created',
|
|
recoverable: true })], [{
|
|
sourceRow: 1,
|
|
status: 'skipped',
|
|
existingPostId: 4 }])[0]
|
|
const failed = mergeImportResults ([buildPostImportRow ({
|
|
skipReason: 'existing',
|
|
existingPostId: 4 })], [{
|
|
sourceRow: 1,
|
|
status: 'failed',
|
|
recoverable: true,
|
|
errors: { base: ['failure'] } }])[0]
|
|
|
|
expect (created).toMatchObject ({
|
|
importStatus: 'created',
|
|
createdPostId: 3,
|
|
existingPostId: undefined,
|
|
recoverable: undefined,
|
|
skipReason: undefined })
|
|
expect (skipped).toMatchObject ({
|
|
importStatus: 'skipped',
|
|
existingPostId: 4,
|
|
createdPostId: undefined,
|
|
recoverable: undefined,
|
|
skipReason: 'existing' })
|
|
expect (failed).toMatchObject ({
|
|
importStatus: 'failed',
|
|
recoverable: true,
|
|
createdPostId: undefined,
|
|
existingPostId: undefined,
|
|
skipReason: undefined,
|
|
importErrors: { base: ['failure'] } })
|
|
})
|
|
|
|
it ('retries only the selected failed row and counts results exclusively', () => {
|
|
const rows = [
|
|
buildPostImportRow ({ sourceRow: 1, importStatus: 'created', createdPostId: 1 }),
|
|
buildPostImportRow ({ sourceRow: 2, importStatus: 'skipped',
|
|
existingPostId: 2, skipReason: 'existing' }),
|
|
buildPostImportRow ({ sourceRow: 3, importStatus: 'failed',
|
|
recoverable: true, importErrors: { base: ['failed'] } }),
|
|
buildPostImportRow ({ sourceRow: 4, importStatus: 'pending',
|
|
recoverable: true, validationErrors: { base: ['failed'] } })]
|
|
|
|
expect (resultSummaryCounts (rows)).toEqual ({ created: 1, skipped: 1, failed: 2 })
|
|
expect (retryImportRow (rows, 3)[2]).toMatchObject ({
|
|
importStatus: 'pending',
|
|
importErrors: undefined })
|
|
expect (retryImportRow (rows, 4)[3]).toBe (rows[3])
|
|
})
|
|
|
|
it ('deduplicates messages and keeps repair mode only for repairable rows', () => {
|
|
const repairable = buildPostImportRow ({
|
|
sourceRow: 1,
|
|
importStatus: 'pending',
|
|
validationErrors: { title: ['invalid'], base: ['duplicate'] },
|
|
importErrors: { base: ['duplicate'], url: ['network'] } })
|
|
const complete = buildPostImportRow ({
|
|
sourceRow: 2,
|
|
importStatus: 'created',
|
|
createdPostId: 2 })
|
|
|
|
expect (resultRowMessages (repairable)).toEqual (['invalid', 'duplicate', 'network'])
|
|
expect (resultRepairMode ([repairable, complete])).toBe ('failed')
|
|
expect (resultRepairMode ([complete])).toBe ('all')
|
|
})
|
|
|
|
it ('classifies editable and retryable rows by terminal and recoverable state', () => {
|
|
const ready = buildPostImportRow ()
|
|
const skipped = buildPostImportRow ({
|
|
importStatus: 'skipped',
|
|
skipReason: 'existing',
|
|
existingPostId: 2 })
|
|
const hardFailed = buildPostImportRow ({
|
|
importStatus: 'failed',
|
|
importErrors: { base: ['failed'] } })
|
|
const pendingInvalid = buildPostImportRow ({
|
|
importStatus: 'pending',
|
|
recoverable: true,
|
|
validationErrors: { title: ['invalid'] } })
|
|
const pendingValid = buildPostImportRow ({
|
|
importStatus: 'pending',
|
|
recoverable: true })
|
|
|
|
expect (canEditReviewRow (ready)).toBe (true)
|
|
expect (canEditReviewRow (skipped)).toBe (false)
|
|
expect (canEditReviewRow (hardFailed)).toBe (false)
|
|
expect (canEditResultRow (pendingInvalid)).toBe (true)
|
|
expect (canRetryResultRow (pendingInvalid)).toBe (false)
|
|
expect (canRetryResultRow (pendingValid)).toBe (true)
|
|
})
|
|
|
|
it ('detects missing, duplicate, and extra source rows exactly', () => {
|
|
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }, { sourceRow: 2 }])).toBe (true)
|
|
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }])).toBe (false)
|
|
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }, { sourceRow: 1 }])).toBe (false)
|
|
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }, { sourceRow: 3 }])).toBe (false)
|
|
})
|
|
|
|
it ('merges only the validated source row and preserves other row edits', () => {
|
|
const edited = buildPostImportRow ({
|
|
sourceRow: 1,
|
|
attributes: { title: 'edited row' },
|
|
provenance: { title: 'manual' } })
|
|
const other = buildPostImportRow ({
|
|
sourceRow: 2,
|
|
attributes: { title: 'keep me' },
|
|
provenance: { title: 'manual' } })
|
|
const validated = buildPostImportRow ({
|
|
sourceRow: 1,
|
|
attributes: { title: 'validated row' } })
|
|
|
|
const result = mergeValidatedImportRow ([edited, other], validated)
|
|
|
|
expect (result[0]?.attributes.title).toBe ('validated row')
|
|
expect (result[1]?.attributes.title).toBe ('keep me')
|
|
})
|
|
|
|
it ('replaces only the targeted source row and preserves the others', () => {
|
|
const original = buildPostImportRow ({
|
|
sourceRow: 1,
|
|
importStatus: 'failed',
|
|
recoverable: true,
|
|
importErrors: { base: ['failed'] } })
|
|
const other = buildPostImportRow ({
|
|
sourceRow: 2,
|
|
attributes: { title: 'keep edited row' } })
|
|
const restored = buildPostImportRow ({
|
|
sourceRow: 1,
|
|
importStatus: 'failed',
|
|
recoverable: true,
|
|
importErrors: { base: ['failed'] } })
|
|
|
|
const result = replaceImportRow ([original, other], restored)
|
|
|
|
expect (result[0]).toEqual (restored)
|
|
expect (result[1]?.attributes.title).toBe ('keep edited row')
|
|
})
|
|
|
|
it ('copies reset snapshot values instead of sharing mutable records', () => {
|
|
const row = buildPostImportRow ({ fieldWarnings: { title: ['warning'] } })
|
|
const initialised = initialisePreviewRows ([row])[0]
|
|
expect (initialised).toBeDefined ()
|
|
if (initialised == null)
|
|
return
|
|
|
|
initialised.attributes.title = 'changed'
|
|
initialised.fieldWarnings.title?.push ('another')
|
|
|
|
expect (initialised.resetSnapshot.attributes.title).toBe ('')
|
|
expect (initialised.resetSnapshot.fieldWarnings.title).toEqual (['warning'])
|
|
})
|
|
|
|
it ('builds the next edited row with shared repair semantics', () => {
|
|
const row = buildPostImportRow ({
|
|
url: 'https://example.com/original',
|
|
importStatus: 'failed',
|
|
recoverable: true,
|
|
importErrors: { base: ['failed'] },
|
|
attributes: { title: 'old title', tags: 'old-tag', duration: '2' },
|
|
provenance: { title: 'automatic', tags: 'automatic', url: 'manual' },
|
|
tagSources: { automatic: 'old-tag', manual: '' } })
|
|
|
|
const nextRow = buildNextEditedRow (
|
|
row,
|
|
{
|
|
url: 'https://example.com/edited',
|
|
title: 'edited title',
|
|
thumbnailBase: '',
|
|
originalCreatedFrom: '',
|
|
originalCreatedBefore: '',
|
|
tags: 'edited-tag',
|
|
parentPostIds: '' },
|
|
true)
|
|
|
|
expect (nextRow.importStatus).toBe ('pending')
|
|
expect (nextRow.importErrors).toBeUndefined ()
|
|
expect (nextRow.url).toBe ('https://example.com/edited')
|
|
expect (nextRow.attributes.title).toBe ('edited title')
|
|
expect (nextRow.attributes.duration).toBe ('2')
|
|
expect (nextRow.attributes.tags).toBe ('edited-tag')
|
|
expect (nextRow.provenance.title).toBe ('manual')
|
|
expect (nextRow.provenance.url).toBe ('manual')
|
|
expect (nextRow.tagSources?.manual).toBe ('edited-tag')
|
|
})
|
|
})
|