jest.mock('react-native-keychain', () => ({ ACCESSIBLE: { AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 'device-only' }, SECURITY_LEVEL: { SECURE_SOFTWARE: 1 }, STORAGE_TYPE: { AES_GCM_NO_AUTH: 'aes-gcm' }, getGenericPassword: jest.fn().mockResolvedValue(false), setGenericPassword: jest.fn(), resetGenericPassword: jest.fn(), getAllGenericPasswordServices: jest.fn(), })) interface RecordedQuery { table: string calls: Array<[string, unknown[]]> } type QueryResult = { data: unknown; error: unknown } const mockQueries: RecordedQuery[] = [] let mockResults: QueryResult[] = [] const mockStorageRemove = jest.fn() function mockBuilder(table: string): Record { const recorded: RecordedQuery = { table, calls: [] } mockQueries.push(recorded) const resolve = (): QueryResult => mockResults.shift() ?? { data: null, error: null } const builder: Record = {} for (const method of ['select', 'update', 'delete', 'eq', 'neq', 'in', 'is', 'order', 'limit']) { builder[method] = (...args: unknown[]) => { recorded.calls.push([method, args]) return builder } } builder.maybeSingle = async () => { recorded.calls.push(['maybeSingle', []]) return resolve() } builder.then = ( onFulfilled: (value: QueryResult) => unknown, onRejected?: (reason: unknown) => unknown, ) => Promise.resolve(resolve()).then(onFulfilled, onRejected) return builder } jest.mock('../src/lib/supabase', () => ({ supabase: { from: jest.fn((table: string) => mockBuilder(table)), storage: { from: jest.fn(() => ({ remove: mockStorageRemove })) }, channel: jest.fn(), removeChannel: jest.fn(), }, })) import type { Meeting } from '@d3ro/api-client' import { createSupabaseLinkedAudioStore, purgeUnlinkedAudio, selectRemovableStorageKeys, type LinkedAudioRef, type LinkedAudioStore, } from '../src/features/audio/linked-audio-cleanup' import { deleteHistoryRevisionSafe, HistoryServiceError, } from '../src/features/history/history-service' import { deleteMeetingRevisionSafe } from '../src/features/meetings/meetings-service' const USER_ID = '11111111-1111-4111-8111-111111111111' const OTHER_USER = '33333333-3333-4333-8333-333333333333' const HISTORY_ID = '22222222-2222-4222-8222-222222222222' const MEETING_ID = '44444444-4444-4444-8444-444444444444' const AUDIO_ID = '55555555-5555-4555-8555-555555555555' const SHA = 'a'.repeat(64) const KEY = `${USER_ID}/imports/${SHA}/voice.m4a` function audio(overrides: Partial = {}): LinkedAudioRef { return { id: AUDIO_ID, storage_key: KEY, upload_status: 'uploaded', ...overrides } } function fakeStore(linked: LinkedAudioRef[]): jest.Mocked { return { findLinked: jest.fn(async () => linked), claimUnlinked: jest.fn(async (_userId: string, ids: string[]) => linked .filter((row) => ids.includes(row.id)) .map((row) => ({ ...row, upload_status: 'deleted' as const }))), findReferencedKeys: jest.fn(async () => []), removeObjects: jest.fn(async () => undefined), deleteRows: jest.fn(async () => undefined), restoreStatus: jest.fn(async () => undefined), } } function meeting(overrides: Partial = {}): Meeting { return { id: MEETING_ID, user_id: USER_ID, team_id: null, title: 'Weekly sync', status: 'completed', started_at: '2026-08-21T00:00:00.000Z', ended_at: '2026-08-21T01:00:00.000Z', duration_ms: 3_600_000, raw_transcript: null, edited_transcript: null, minutes_markdown: null, minutes_json: null, stt_model: 'whisper', llm_model: null, stt_latency_ms: null, llm_latency_ms: null, error_message: null, audio_storage_key: KEY, created_at: '2026-08-21T00:00:00.000Z', updated_at: '2026-08-21T01:00:00.000Z', ...overrides, } } beforeEach(() => { mockQueries.length = 0 mockResults = [] mockStorageRemove.mockReset() }) describe('history delete removes the uploaded original audio', () => { test('deleting a history entry removes its storage object and audio_files row', async () => { const store = fakeStore([audio()]) mockResults = [{ data: { id: HISTORY_ID }, error: null }] await deleteHistoryRevisionSafe(USER_ID, HISTORY_ID, 3, store) expect(store.findLinked).toHaveBeenCalledWith(USER_ID, 'history_id', HISTORY_ID) expect(store.claimUnlinked).toHaveBeenCalledWith(USER_ID, [AUDIO_ID]) expect(store.removeObjects).toHaveBeenCalledWith([KEY]) expect(store.deleteRows).toHaveBeenCalledWith(USER_ID, [AUDIO_ID]) // 스냅샷은 부모 삭제 전에 찍어야 한다(삭제 후엔 history_id 가 NULL 이 된다). expect(store.findLinked.mock.invocationCallOrder[0]) .toBeLessThan(store.claimUnlinked.mock.invocationCallOrder[0]) }) test('a revision conflict leaves the audio untouched', async () => { const store = fakeStore([audio()]) mockResults = [{ data: null, error: null }] await expect(deleteHistoryRevisionSafe(USER_ID, HISTORY_ID, 3, store)) .rejects.toMatchObject({ code: 'conflict' }) expect(store.claimUnlinked).not.toHaveBeenCalled() expect(store.removeObjects).not.toHaveBeenCalled() expect(store.deleteRows).not.toHaveBeenCalled() }) test('a failed audio lookup aborts the delete instead of orphaning audio', async () => { const store = fakeStore([audio()]) store.findLinked.mockRejectedValueOnce(new TypeError('Network request failed')) await expect(deleteHistoryRevisionSafe(USER_ID, HISTORY_ID, 3, store)) .rejects.toBeInstanceOf(HistoryServiceError) expect(mockQueries.filter((query) => query.table === 'history')).toHaveLength(0) }) test('audio cleanup failure does not turn a completed delete into an error', async () => { const store = fakeStore([audio()]) store.removeObjects.mockRejectedValueOnce(new Error('storage unavailable')) mockResults = [{ data: { id: HISTORY_ID }, error: null }] await expect(deleteHistoryRevisionSafe(USER_ID, HISTORY_ID, 3, store)).resolves.toBeUndefined() // 저장소 삭제 실패 시 선점을 되돌려 같은 파일 재가져오기가 막히지 않게 한다. expect(store.restoreStatus).toHaveBeenCalledWith(USER_ID, AUDIO_ID, 'uploaded') expect(store.deleteRows).not.toHaveBeenCalled() }) }) describe('meeting delete removes the uploaded original audio', () => { test('deleting a meeting removes its storage object and audio_files row', async () => { const store = fakeStore([audio()]) mockResults = [{ data: { id: MEETING_ID }, error: null }] await deleteMeetingRevisionSafe(USER_ID, meeting(), store) expect(store.findLinked).toHaveBeenCalledWith(USER_ID, 'meeting_id', MEETING_ID) expect(store.removeObjects).toHaveBeenCalledWith([KEY]) expect(store.deleteRows).toHaveBeenCalledWith(USER_ID, [AUDIO_ID]) }) test('a meeting conflict leaves the audio untouched', async () => { const store = fakeStore([audio()]) mockResults = [{ data: null, error: null }] await expect(deleteMeetingRevisionSafe(USER_ID, meeting(), store)) .rejects.toMatchObject({ code: 'conflict' }) expect(store.removeObjects).not.toHaveBeenCalled() }) }) describe('purgeUnlinkedAudio policy', () => { test('rows re-linked to another owner before cleanup are not removed', async () => { const store = fakeStore([audio()]) store.claimUnlinked.mockResolvedValueOnce([]) const result = await purgeUnlinkedAudio(store, USER_ID, [audio()]) expect(result).toEqual({ removedKeys: [], deletedRowIds: [], failed: false }) expect(store.removeObjects).not.toHaveBeenCalled() }) test('a storage key still referenced by another row is kept', async () => { const store = fakeStore([audio()]) store.findReferencedKeys.mockResolvedValueOnce([KEY]) const result = await purgeUnlinkedAudio(store, USER_ID, [audio()]) expect(store.removeObjects).not.toHaveBeenCalled() expect(result.deletedRowIds).toEqual([AUDIO_ID]) }) test('selectRemovableStorageKeys keeps only own, unreferenced, unique keys', () => { const rows = [ audio(), audio({ id: 'b' }), audio({ id: 'c', storage_key: `${OTHER_USER}/imports/${SHA}/x.m4a` }), audio({ id: 'd', storage_key: `${USER_ID}/../${OTHER_USER}/x.m4a` }), audio({ id: 'e', storage_key: `${USER_ID}/imports/${'b'.repeat(64)}/y.m4a` }), ] expect(selectRemovableStorageKeys(USER_ID, rows, [`${USER_ID}/imports/${'b'.repeat(64)}/y.m4a`])) .toEqual([KEY]) }) }) describe('Supabase linked audio adapter', () => { test('claims only rows that are still unlinked, scoped to the user', async () => { mockResults = [{ data: [audio({ upload_status: 'deleted' })], error: null }] const claimed = await createSupabaseLinkedAudioStore().claimUnlinked(USER_ID, [AUDIO_ID]) expect(claimed).toEqual([audio({ upload_status: 'deleted' })]) const query = mockQueries[0] expect(query.table).toBe('audio_files') expect(query.calls).toEqual(expect.arrayContaining([ ['update', [{ upload_status: 'deleted' }]], ['eq', ['user_id', USER_ID]], ['in', ['id', [AUDIO_ID]]], ['is', ['history_id', null]], ['is', ['meeting_id', null]], ])) }) test('removes objects from the audio bucket and surfaces storage errors', async () => { mockStorageRemove.mockResolvedValueOnce({ data: [], error: null }) await createSupabaseLinkedAudioStore().removeObjects([KEY]) expect(mockStorageRemove).toHaveBeenCalledWith([KEY]) mockStorageRemove.mockResolvedValueOnce({ data: null, error: new Error('denied') }) await expect(createSupabaseLinkedAudioStore().removeObjects([KEY])).rejects.toThrow('denied') }) })