import { describe, expect, it } from 'vitest'; import { SourceReattachmentError, bestReattachmentCandidate, confirmSourceReattachment, rankReattachmentCandidates, } from '../../../src/project/source-reattachment'; import type { SourceHash } from '../../../src/project/project.types'; import { UPDATED_AT, mediaAsset, populatedProject } from './project-fixture'; const HASH_A: SourceHash = { algorithm: 'sha-256', value: 'a'.repeat(64), scope: 'full', }; const HASH_B: SourceHash = { algorithm: 'sha-256', value: 'b'.repeat(64), scope: 'full', }; describe('source reattachment matching', () => { it('ranks size, timestamp, filename, MIME type and optional hash evidence', () => { const asset = mediaAsset({ sourceStatus: 'missing', hash: HASH_A }); const candidates = [ { name: 'renamed.mp4', size: asset.size, lastModified: asset.lastModified, mimeType: asset.mimeType, }, { name: asset.originalName, size: asset.size, lastModified: asset.lastModified, mimeType: asset.mimeType, hash: HASH_A, }, { name: asset.originalName, size: asset.size, lastModified: asset.lastModified, mimeType: asset.mimeType, hash: HASH_B, }, ]; const matches = rankReattachmentCandidates(asset, candidates); expect(matches.map((match) => match.confidence)).toEqual([ 'exact', 'likely', 'none', ]); expect(matches[0]).toMatchObject({ score: 190, requiresExplicitConfirmation: true, }); expect( matches[0]?.evidence.find((evidence) => evidence.field === 'hash') ).toMatchObject({ matches: true }); }); it('does not suggest ambiguous ties', () => { const asset = mediaAsset({ sourceStatus: 'missing' }); const candidates = [ { name: 'a.mp4', size: asset.size, lastModified: asset.lastModified, mimeType: asset.mimeType, }, { name: 'b.mp4', size: asset.size, lastModified: asset.lastModified, mimeType: asset.mimeType, }, ]; expect(bestReattachmentCandidate(asset, candidates)).toBeUndefined(); }); it('allows filesystem timestamp tolerance but treats size/hash mismatch as unsafe', () => { const asset = mediaAsset({ sourceStatus: 'missing', hash: HASH_A }); expect( rankReattachmentCandidates(asset, [ { name: asset.originalName, size: asset.size, lastModified: asset.lastModified + 1_500, mimeType: asset.mimeType, }, ])[0]?.confidence ).toBe('likely'); expect( rankReattachmentCandidates(asset, [ { name: asset.originalName, size: asset.size + 1, lastModified: asset.lastModified, mimeType: asset.mimeType, }, ])[0]?.confidence ).toBe('none'); }); it('does not treat incomparable partial and full hashes as a mismatch', () => { const asset = mediaAsset({ sourceStatus: 'missing', hash: HASH_A }); const partial: SourceHash = { algorithm: 'sha-256', value: 'b'.repeat(64), scope: 'partial', bytesHashed: 1_024, }; expect( rankReattachmentCandidates(asset, [ { name: asset.originalName, size: asset.size, lastModified: asset.lastModified, mimeType: asset.mimeType, hash: partial, }, ])[0]?.confidence ).toBe('likely'); }); it('rejects malformed file descriptors before scoring', () => { expect(() => rankReattachmentCandidates(mediaAsset(), [ { name: '', size: -1, lastModified: Number.NaN, }, ]) ).toThrow(SourceReattachmentError); }); it('only marks a source attached after an explicit confirmation call', () => { const project = { ...populatedProject(), assets: [mediaAsset({ sourceStatus: 'missing' })], }; const candidate = { name: 'renamed.mp4', size: 1_024, lastModified: 1_700_000_000_000, mimeType: 'video/mp4', }; const result = confirmSourceReattachment( project, 'asset-1', candidate, UPDATED_AT ); expect(project.assets[0]?.sourceStatus).toBe('missing'); expect(result.assets[0]?.sourceStatus).toBe('attached'); expect(() => confirmSourceReattachment( project, 'asset-1', { ...candidate, size: 1 }, UPDATED_AT ) ).toThrow(SourceReattachmentError); }); });