Files
ComfyUI_frontend/src/platform/missingMedia/missingMediaScan.test.ts
jaeone94 9be62a1845 [backport core/1.44] fix: suppress missing media scan during uploads (#12111) (#12188)
## Summary

Manual backport of #12111 to `core/1.44`.

This suppresses false-positive missing media detection while media
loader nodes are still uploading files from drag/drop, paste, or
file-select flows.

## Conflict Resolution

The cherry-pick conflicted only in
`src/platform/missingMedia/missingMediaScan.test.ts` because the target
branch still has the older annotated-media parameterized test block
around the insertion point. I resolved it by:

- adding the new upload-state tests from #12111 above the existing
annotated-media cases
- keeping the existing release-branch annotated-media `it.each` cases
intact
- using `it.for([false, true])` only for the new upload-state test added
by #12111

## Validation

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run
src/platform/missingMedia/missingMediaScan.test.ts
src/composables/node/useNodeImageUpload.test.ts
src/extensions/core/uploadAudio.test.ts
src/composables/graph/useErrorClearingHooks.test.ts`

Result: 4 files passed, 87 tests passed.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12188-backport-core-1-44-fix-suppress-missing-media-scan-during-uploads-12111-35e6d73d3650812dab83ccf51411cc88)
by [Unito](https://www.unito.io)
2026-05-12 20:37:47 +09:00

982 lines
26 KiB
TypeScript

import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type * as AssetServiceModule from '@/platform/assets/services/assetService'
import type * as FetchJobsModule from '@/platform/remote/comfyui/jobs/fetchJobs'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { MissingMediaAssetResolver } from './missingMediaAssetResolver'
import {
scanAllMediaCandidates,
scanNodeMediaCandidates,
verifyMediaCandidates,
groupCandidatesByName,
groupCandidatesByMediaType
} from './missingMediaScan'
import type { MissingMediaCandidate } from './types'
const { mockGetInputAssetsIncludingPublic, mockGetAssetsPageByTag } =
vi.hoisted(() => ({
mockGetInputAssetsIncludingPublic: vi.fn(),
mockGetAssetsPageByTag: vi.fn()
}))
const { mockFetchHistoryPage } = vi.hoisted(() => ({
mockFetchHistoryPage: vi.fn()
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
getExecutionIdByNode: (
_graph: unknown,
node: { _testExecutionId?: string; id: number }
) => node._testExecutionId ?? String(node.id)
}))
vi.mock('@/platform/assets/services/assetService', async () => {
const actual = await vi.importActual<typeof AssetServiceModule>(
'@/platform/assets/services/assetService'
)
return {
...actual,
assetService: {
...actual.assetService,
getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic,
getAssetsPageByTag: mockGetAssetsPageByTag
}
}
})
vi.mock('@/platform/remote/comfyui/jobs/fetchJobs', async () => {
const actual = await vi.importActual<typeof FetchJobsModule>(
'@/platform/remote/comfyui/jobs/fetchJobs'
)
return {
...actual,
fetchHistoryPage: mockFetchHistoryPage
}
})
function makeCandidate(
nodeId: string,
name: string,
overrides: Partial<MissingMediaCandidate> = {}
): MissingMediaCandidate {
return {
nodeId,
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name,
isMissing: true,
...overrides
}
}
function makeMediaCombo(
name: string,
value: string,
options: string[] = []
): IComboWidget {
return fromAny<IComboWidget, unknown>({
type: 'combo',
name,
value,
options: { values: options }
})
}
function makeMediaNode(
id: number,
type: string,
widgets: IComboWidget[],
mode: number = 0,
executionId?: string
): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
type,
widgets,
mode,
_testExecutionId: executionId ?? String(id)
})
}
function makeGraph(nodes: LGraphNode[]): LGraph {
return fromAny<LGraph, unknown>({ _testNodes: nodes })
}
function makeAsset(name: string, assetHash: string | null = null): AssetItem {
return {
id: name,
name,
asset_hash: assetHash,
mime_type: null,
tags: ['input']
}
}
function makeAssetResolver(
inputAssets: AssetItem[],
generatedAssets: AssetItem[] = []
): MissingMediaAssetResolver {
return vi.fn(async () => ({ inputAssets, generatedAssets }))
}
function makeAssetPage(
assets: AssetItem[],
options: { hasMore?: boolean; total?: number } = {}
) {
return {
assets,
total: options.total ?? assets.length,
has_more: options.hasMore ?? false
}
}
function makeHistoryJob(
filename: string,
options: { id?: string; subfolder?: string } = {}
): JobListItem {
return fromAny<JobListItem, unknown>({
id: options.id ?? filename,
status: 'completed',
create_time: 0,
priority: 0,
preview_output: {
filename,
subfolder: options.subfolder ?? '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
})
}
describe('scanNodeMediaCandidates', () => {
it('returns candidate for a LoadImage node with missing image', () => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
'LoadImage',
[makeMediaCombo('image', 'photo.png', ['other.png'])],
0
)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'photo.png',
isMissing: true
})
})
it('returns empty for non-media node types', () => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
'KSampler',
[makeMediaCombo('sampler', 'euler', ['euler', 'dpm'])],
0
)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result).toEqual([])
})
it('returns empty for node with no widgets', () => {
const graph = makeGraph([])
const node = makeMediaNode(1, 'LoadImage', [], 0)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result).toEqual([])
})
it.for([false, true])(
'returns empty while a media upload is pending on the node (isCloud: %s)',
(isCloud) => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
'LoadVideo',
[makeMediaCombo('file', 'clip.mp4', [])],
0
)
node.isUploading = true
const result = scanNodeMediaCandidates(graph, node, isCloud)
expect(result).toEqual([])
}
)
it('detects missing media again after upload state clears', () => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
'LoadVideo',
[makeMediaCombo('file', 'clip.mp4', [])],
0
)
node.isUploading = true
expect(scanNodeMediaCandidates(graph, node, false)).toEqual([])
node.isUploading = false
expect(scanNodeMediaCandidates(graph, node, false)).toEqual([
expect.objectContaining({
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video',
name: 'clip.mp4',
isMissing: true
})
])
})
it.each([
{
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
value: 'photo.png [input]',
option: 'photo.png'
},
{
nodeType: 'LoadImageMask',
widgetName: 'image',
mediaType: 'image',
value: 'mask.png [input]',
option: 'mask.png'
},
{
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video',
value: 'clip.mp4 [input]',
option: 'clip.mp4'
},
{
nodeType: 'LoadAudio',
widgetName: 'audio',
mediaType: 'audio',
value: 'sound.wav [input]',
option: 'sound.wav'
}
])(
'matches annotated $nodeType values against clean OSS options',
({ nodeType, widgetName, mediaType, value, option }) => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
nodeType,
[makeMediaCombo(widgetName, value, [option])],
0
)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
nodeType,
widgetName,
mediaType,
name: value,
isMissing: false
})
}
)
it.each([
{
nodeType: 'LoadImage',
widgetName: 'image',
value: 'photo.png [output]'
},
{
nodeType: 'LoadVideo',
widgetName: 'file',
value: 'clip.mp4 [output]'
},
{
nodeType: 'LoadAudio',
widgetName: 'audio',
value: 'sound.wav [output]'
}
])(
'leaves OSS $nodeType output annotations pending when not in options',
({ nodeType, widgetName, value }) => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
nodeType,
[makeMediaCombo(widgetName, value, ['other-file.png', value])],
0
)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result[0]).toMatchObject({
nodeType,
widgetName,
name: value,
isMissing: undefined
})
}
)
it('marks OSS input annotations missing when the clean option is absent', () => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
'LoadImage',
[makeMediaCombo('image', 'photo.png [input]', ['other.png'])],
0
)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result[0]).toMatchObject({
name: 'photo.png [input]',
isMissing: true
})
})
it('does not treat compact Cloud annotations as valid OSS options', () => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
'LoadImage',
[makeMediaCombo('image', 'photo.png[input]', ['photo.png'])],
0
)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result[0]).toMatchObject({
name: 'photo.png[input]',
isMissing: true
})
})
})
describe('scanAllMediaCandidates', () => {
it('skips muted nodes (mode === NEVER)', () => {
const node = makeMediaNode(
1,
'LoadImage',
[makeMediaCombo('image', 'photo.png', ['other.png'])],
2 // NEVER
)
const result = scanAllMediaCandidates(makeGraph([node]), false)
expect(result).toHaveLength(0)
})
it('skips bypassed nodes (mode === BYPASS)', () => {
const node = makeMediaNode(
2,
'LoadImage',
[makeMediaCombo('image', 'photo.png', ['other.png'])],
4 // BYPASS
)
const result = scanAllMediaCandidates(makeGraph([node]), false)
expect(result).toHaveLength(0)
})
it('includes active nodes (mode === ALWAYS)', () => {
const node = makeMediaNode(
3,
'LoadImage',
[makeMediaCombo('image', 'photo.png', ['other.png'])],
0 // ALWAYS
)
const result = scanAllMediaCandidates(makeGraph([node]), false)
expect(result).toHaveLength(1)
expect(result[0].isMissing).toBe(true)
})
})
describe('groupCandidatesByName', () => {
it('groups candidates with the same name', () => {
const candidates = [
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'photo.png'),
makeCandidate('3', 'other.png')
]
const result = groupCandidatesByName(candidates)
expect(result).toHaveLength(2)
const photoGroup = result.find((g) => g.name === 'photo.png')
expect(photoGroup?.referencingNodes).toHaveLength(2)
expect(photoGroup?.mediaType).toBe('image')
const otherGroup = result.find((g) => g.name === 'other.png')
expect(otherGroup?.referencingNodes).toHaveLength(1)
})
it('returns empty array for empty input', () => {
expect(groupCandidatesByName([])).toEqual([])
})
})
describe('groupCandidatesByMediaType', () => {
it('groups by media type in order: image, video, audio', () => {
const candidates = [
makeCandidate('1', 'sound.mp3', {
nodeType: 'LoadAudio',
widgetName: 'audio',
mediaType: 'audio'
}),
makeCandidate('2', 'photo.png'),
makeCandidate('3', 'clip.mp4', {
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video'
})
]
const result = groupCandidatesByMediaType(candidates)
expect(result).toHaveLength(3)
expect(result[0].mediaType).toBe('image')
expect(result[1].mediaType).toBe('video')
expect(result[2].mediaType).toBe('audio')
})
it('omits media types with no candidates', () => {
const candidates = [
makeCandidate('1', 'clip.mp4', {
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video'
})
]
const result = groupCandidatesByMediaType(candidates)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('video')
})
it('groups multiple names within the same media type', () => {
const candidates = [
makeCandidate('1', 'a.png'),
makeCandidate('2', 'b.png'),
makeCandidate('3', 'a.png')
]
const result = groupCandidatesByMediaType(candidates)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('image')
expect(result[0].items).toHaveLength(2)
expect(
result[0].items.find((i) => i.name === 'a.png')?.referencingNodes
).toHaveLength(2)
})
})
describe('verifyMediaCandidates', () => {
const existingHash =
'blake3:1111111111111111111111111111111111111111111111111111111111111111'
const missingHash =
'blake3:2222222222222222222222222222222222222222222222222222222222222222'
beforeEach(() => {
vi.clearAllMocks()
mockGetInputAssetsIncludingPublic.mockResolvedValue([])
mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([]))
mockFetchHistoryPage.mockResolvedValue({
jobs: [],
total: 0,
offset: 0,
limit: 200,
hasMore: false
})
})
it('matches candidates by available input asset name or hash', async () => {
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined }),
makeCandidate('2', existingHash, { isMissing: undefined }),
makeCandidate('3', missingHash, { isMissing: undefined })
]
const resolveAssetSources = makeAssetResolver([
makeAsset('photo.png', existingHash)
])
await verifyMediaCandidates(candidates, {
isCloud: true,
resolveAssetSources
})
expect(candidates[0].isMissing).toBe(false)
expect(candidates[1].isMissing).toBe(false)
expect(candidates[2].isMissing).toBe(true)
expect(resolveAssetSources).toHaveBeenCalledWith({
signal: undefined,
isCloud: true,
includeGeneratedAssets: false,
generatedMatchNames: new Set(),
allowCompactSuffix: true
})
})
it('matches asset names when asset_hash is null', async () => {
const candidates = [
makeCandidate('1', 'legacy-photo.png', { isMissing: undefined }),
makeCandidate('2', 'missing-photo.png', { isMissing: undefined })
]
const resolveAssetSources = makeAssetResolver([
makeAsset('legacy-photo.png', null)
])
await verifyMediaCandidates(candidates, {
isCloud: true,
resolveAssetSources
})
expect(candidates[0].isMissing).toBe(false)
expect(candidates[1].isMissing).toBe(true)
})
it('matches annotated candidate names against clean asset names', async () => {
const candidates = [
makeCandidate('1', 'photo.png [input]', { isMissing: undefined }),
makeCandidate('2', 'clip.mp4[input]', {
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video',
isMissing: undefined
}),
makeCandidate('3', 'missing.wav [output]', {
nodeType: 'LoadAudio',
widgetName: 'audio',
mediaType: 'audio',
isMissing: undefined
})
]
const resolveAssetSources = makeAssetResolver(
[makeAsset('photo.png'), makeAsset('clip.mp4')],
[]
)
await verifyMediaCandidates(candidates, {
isCloud: true,
resolveAssetSources
})
expect(candidates[0]).toMatchObject({
name: 'photo.png [input]',
isMissing: false
})
expect(candidates[1]).toMatchObject({
name: 'clip.mp4[input]',
isMissing: false
})
expect(candidates[2]).toMatchObject({
name: 'missing.wav [output]',
isMissing: true
})
})
it('matches output hash filenames against generated media assets', async () => {
const candidates = [
makeCandidate(
'1',
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png [output]',
{
isMissing: undefined
}
)
]
const resolveAssetSources = makeAssetResolver(
[],
[
makeAsset(
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
)
]
)
await verifyMediaCandidates(candidates, {
isCloud: true,
resolveAssetSources
})
expect(resolveAssetSources).toHaveBeenCalledWith({
signal: undefined,
isCloud: true,
includeGeneratedAssets: true,
generatedMatchNames: new Set([
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
]),
allowCompactSuffix: true
})
expect(candidates[0]).toMatchObject({
name: '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png [output]',
isMissing: false
})
})
it('does not satisfy output annotations with input assets of the same name', async () => {
const candidates = [
makeCandidate('1', 'photo.png [output]', { isMissing: undefined })
]
const resolveAssetSources = makeAssetResolver([makeAsset('photo.png')])
await verifyMediaCandidates(candidates, {
isCloud: true,
resolveAssetSources
})
expect(candidates[0].isMissing).toBe(true)
})
it('does not satisfy input candidates with output assets of the same name', async () => {
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
]
const resolveAssetSources = makeAssetResolver([], [makeAsset('photo.png')])
await verifyMediaCandidates(candidates, {
isCloud: true,
resolveAssetSources
})
expect(candidates[0].isMissing).toBe(true)
})
it('verifies OSS output candidates against generated history without cloud assets', async () => {
const candidates = [
makeCandidate('1', 'subfolder/photo.png [output]', {
isMissing: undefined
})
]
mockFetchHistoryPage.mockResolvedValueOnce({
jobs: [makeHistoryJob('photo.png', { subfolder: 'subfolder' })],
total: 1,
offset: 0,
limit: 200,
hasMore: false
})
await verifyMediaCandidates(candidates, { isCloud: false })
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
expect(mockFetchHistoryPage).toHaveBeenCalledWith(
expect.any(Function),
200,
0
)
expect(candidates[0]).toMatchObject({
name: 'subfolder/photo.png [output]',
isMissing: false
})
})
it('does not normalize compact annotations when verifying OSS candidates', async () => {
const candidates = [
makeCandidate('1', 'photo.png[output]', { isMissing: undefined })
]
const resolveAssetSources = makeAssetResolver([makeAsset('photo.png')])
await verifyMediaCandidates(candidates, {
isCloud: false,
resolveAssetSources
})
expect(resolveAssetSources).toHaveBeenCalledWith({
signal: undefined,
isCloud: false,
includeGeneratedAssets: false,
generatedMatchNames: new Set(),
allowCompactSuffix: false
})
expect(candidates[0].isMissing).toBe(true)
})
it('matches when the asset identifier itself is annotated', async () => {
const candidates = [
makeCandidate('1', 'clip.mp4[output]', { isMissing: undefined })
]
const resolveAssetSources = makeAssetResolver(
[],
[makeAsset('clip.mp4 [output]')]
)
await verifyMediaCandidates(candidates, {
isCloud: true,
resolveAssetSources
})
expect(candidates[0].isMissing).toBe(false)
})
it('marks pending candidates missing when no input assets are available', async () => {
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
]
await verifyMediaCandidates(candidates, {
isCloud: true,
resolveAssetSources: makeAssetResolver([])
})
expect(candidates[0].isMissing).toBe(true)
})
it('uses public input assets by default', async () => {
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
mockGetInputAssetsIncludingPublic.mockResolvedValue([
makeAsset('stored-photo.png', existingHash)
])
await verifyMediaCandidates(candidates, { isCloud: true })
expect(candidates[0].isMissing).toBe(false)
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
expect.any(AbortSignal)
)
expect(mockFetchHistoryPage).not.toHaveBeenCalled()
})
it('reads cloud output assets by tag for output candidates', async () => {
const outputHash =
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
const candidates = [
makeCandidate('1', `${outputHash} [output]`, { isMissing: undefined })
]
mockGetAssetsPageByTag.mockResolvedValue(
makeAssetPage([makeAsset(outputHash)])
)
await verifyMediaCandidates(candidates, { isCloud: true })
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
expect.any(AbortSignal)
)
expect(mockGetAssetsPageByTag).toHaveBeenCalledWith(
'output',
true,
expect.objectContaining({
limit: 500,
offset: 0,
signal: expect.any(AbortSignal)
})
)
expect(mockFetchHistoryPage).not.toHaveBeenCalled()
expect(candidates[0].isMissing).toBe(false)
})
it('walks OSS generated history pages until hasMore is false', async () => {
const outputHash =
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
const candidates = [
makeCandidate('1', `${outputHash} [output]`, { isMissing: undefined })
]
mockFetchHistoryPage
.mockResolvedValueOnce({
jobs: Array.from({ length: 200 }, (_, index) =>
makeHistoryJob(`other-${index}.png`)
),
total: 201,
offset: 0,
limit: 200,
hasMore: true
})
.mockResolvedValueOnce({
jobs: [makeHistoryJob(outputHash)],
total: 201,
offset: 200,
limit: 200,
hasMore: false
})
await verifyMediaCandidates(candidates, { isCloud: false })
expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
1,
expect.any(Function),
200,
0
)
expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
2,
expect.any(Function),
200,
200
)
expect(candidates[0].isMissing).toBe(false)
})
it('trusts OSS history hasMore instead of page length', async () => {
const candidates = [
makeCandidate('1', 'missing-output.png [output]', {
isMissing: undefined
})
]
mockFetchHistoryPage.mockResolvedValueOnce({
jobs: Array.from({ length: 200 }, (_, index) =>
makeHistoryJob(`other-${index}.png`)
),
total: 200,
offset: 0,
limit: 200,
hasMore: false
})
await verifyMediaCandidates(candidates, { isCloud: false })
expect(mockFetchHistoryPage).toHaveBeenCalledOnce()
expect(candidates[0].isMissing).toBe(true)
})
it('respects abort signal before execution', async () => {
const controller = new AbortController()
controller.abort()
const candidates = [
makeCandidate('1', missingHash, { isMissing: undefined })
]
await verifyMediaCandidates(candidates, {
isCloud: true,
signal: controller.signal
})
expect(candidates[0].isMissing).toBeUndefined()
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
})
it('respects abort signal after loading input assets', async () => {
const controller = new AbortController()
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
const resolveAssetSources: MissingMediaAssetResolver = vi.fn(async () => {
controller.abort()
return {
inputAssets: [makeAsset('stored-photo.png', existingHash)],
generatedAssets: []
}
})
await verifyMediaCandidates(candidates, {
isCloud: true,
signal: controller.signal,
resolveAssetSources
})
expect(candidates[0].isMissing).toBeUndefined()
})
it('skips candidates already resolved as true', async () => {
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
await verifyMediaCandidates(candidates, { isCloud: true })
expect(candidates[0].isMissing).toBe(true)
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
})
it('skips candidates already resolved as false', async () => {
const candidates = [makeCandidate('1', existingHash, { isMissing: false })]
await verifyMediaCandidates(candidates, { isCloud: true })
expect(candidates[0].isMissing).toBe(false)
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
})
it('skips entirely when no pending candidates', async () => {
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
await verifyMediaCandidates(candidates, { isCloud: true })
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
})
it('loads public input assets for default verification', async () => {
const candidates = [
makeCandidate('1', 'public-photo.png', { isMissing: undefined })
]
const inputAssets = Array.from({ length: 500 }, (_, index) =>
makeAsset(`asset-${index}.png`)
)
inputAssets[42] = makeAsset('public-asset-record', 'public-photo.png')
mockGetInputAssetsIncludingPublic.mockResolvedValue(inputAssets)
await verifyMediaCandidates(candidates, { isCloud: true })
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
expect.any(AbortSignal)
)
expect(candidates[0].isMissing).toBe(false)
})
it('silences aborts while loading input assets', async () => {
const abortError = new Error('aborted')
abortError.name = 'AbortError'
const controller = new AbortController()
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
]
const resolveAssetSources: MissingMediaAssetResolver = vi.fn(async () => {
controller.abort()
throw abortError
})
await expect(
verifyMediaCandidates(candidates, {
isCloud: true,
signal: controller.signal,
resolveAssetSources
})
).resolves.toBeUndefined()
expect(candidates[0].isMissing).toBeUndefined()
})
it('forwards the signal to the default input asset fetcher and silences aborts', async () => {
const abortError = new Error('aborted')
abortError.name = 'AbortError'
const controller = new AbortController()
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
]
let serviceSignal: AbortSignal | undefined
mockGetInputAssetsIncludingPublic.mockImplementationOnce(
async (signal?: AbortSignal) => {
serviceSignal = signal
controller.abort()
throw abortError
}
)
await expect(
verifyMediaCandidates(candidates, {
isCloud: true,
signal: controller.signal
})
).resolves.toBeUndefined()
expect(serviceSignal).toBeInstanceOf(AbortSignal)
expect(serviceSignal?.aborted).toBe(true)
expect(candidates[0].isMissing).toBeUndefined()
})
})