254 lines
9.5 KiB
TypeScript
254 lines
9.5 KiB
TypeScript
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<string, unknown> {
|
|
const recorded: RecordedQuery = { table, calls: [] }
|
|
mockQueries.push(recorded)
|
|
const resolve = (): QueryResult => mockResults.shift() ?? { data: null, error: null }
|
|
const builder: Record<string, unknown> = {}
|
|
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> = {}): LinkedAudioRef {
|
|
return { id: AUDIO_ID, storage_key: KEY, upload_status: 'uploaded', ...overrides }
|
|
}
|
|
|
|
function fakeStore(linked: LinkedAudioRef[]): jest.Mocked<LinkedAudioStore> {
|
|
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> = {}): 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')
|
|
})
|
|
})
|