refactor(import): simplify API failure handling

- rely on ky for transport errors across import/staging calls
- keep explicit soft-failure checks for auto-import approval endpoints
- add regression test for approval/rejection soft failures
This commit is contained in:
Antti Kettunen 2026-05-17 19:39:11 +03:00
parent d32d88ea0e
commit 9f2c5c685b
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
2 changed files with 103 additions and 132 deletions

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { HttpResponse, http, server } from '@/test/msw';
import { approveAutoImportResult, rejectAutoImportResult } from './-import.api';
const softFailureMessage = 'Item not found or not pending review';
describe('import api', () => {
it('surfaces soft failures from auto-import approval endpoints', async () => {
server.use(
http.post('/api/auto-import/approve/17', () =>
HttpResponse.json({
success: false,
error: softFailureMessage,
}),
),
http.post('/api/auto-import/reject/18', () =>
HttpResponse.json({
success: false,
error: softFailureMessage,
}),
),
);
await expect(approveAutoImportResult(17)).rejects.toThrow(softFailureMessage);
await expect(rejectAutoImportResult(18)).rejects.toThrow(softFailureMessage);
});
});

View file

@ -18,48 +18,26 @@ import type {
export const IMPORT_QUERY_KEY = ['import'] as const; export const IMPORT_QUERY_KEY = ['import'] as const;
function assertSuccess<T extends { success: boolean; error?: string }>(
payload: T,
fallback: string,
): T {
if (!payload.success) {
throw new Error(payload.error || fallback);
}
return payload;
}
export async function fetchImportStagingFiles(): Promise<ImportStagingFilesPayload> { export async function fetchImportStagingFiles(): Promise<ImportStagingFilesPayload> {
return assertSuccess( return readJson<ImportStagingFilesPayload>(apiClient.get('import/staging/files'));
await readJson<ImportStagingFilesPayload>(apiClient.get('import/staging/files')),
'Failed to load import folder',
);
} }
export async function fetchImportStagingGroups(): Promise<ImportStagingGroupsPayload> { export async function fetchImportStagingGroups(): Promise<ImportStagingGroupsPayload> {
return assertSuccess( return readJson<ImportStagingGroupsPayload>(apiClient.get('import/staging/groups'));
await readJson<ImportStagingGroupsPayload>(apiClient.get('import/staging/groups')),
'Failed to load auto-detected albums',
);
} }
export async function fetchImportStagingSuggestions(): Promise<ImportAlbumSearchPayload> { export async function fetchImportStagingSuggestions(): Promise<ImportAlbumSearchPayload> {
return assertSuccess( return readJson<ImportAlbumSearchPayload>(apiClient.get('import/staging/suggestions'));
await readJson<ImportAlbumSearchPayload>(apiClient.get('import/staging/suggestions')),
'Failed to load import suggestions',
);
} }
export async function searchImportAlbums(query: string): Promise<ImportAlbumSearchPayload> { export async function searchImportAlbums(query: string): Promise<ImportAlbumSearchPayload> {
return assertSuccess( return readJson<ImportAlbumSearchPayload>(
await readJson<ImportAlbumSearchPayload>( apiClient.get('import/search/albums', {
apiClient.get('import/search/albums', { searchParams: {
searchParams: { q: query,
q: query, limit: '12',
limit: '12', },
}, }),
}),
),
'Album search failed',
); );
} }
@ -70,19 +48,16 @@ export async function matchImportAlbum(input: {
albumArtist?: string | null; albumArtist?: string | null;
filePaths?: string[] | null; filePaths?: string[] | null;
}): Promise<ImportAlbumMatchPayload> { }): Promise<ImportAlbumMatchPayload> {
return assertSuccess( return readJson<ImportAlbumMatchPayload>(
await readJson<ImportAlbumMatchPayload>( apiClient.post('import/album/match', {
apiClient.post('import/album/match', { json: {
json: { album_id: input.albumId,
album_id: input.albumId, source: input.source || '',
source: input.source || '', album_name: input.albumName || '',
album_name: input.albumName || '', album_artist: input.albumArtist || '',
album_artist: input.albumArtist || '', ...(input.filePaths?.length ? { file_paths: input.filePaths } : {}),
...(input.filePaths?.length ? { file_paths: input.filePaths } : {}), },
}, }),
}),
),
'Failed to match album',
); );
} }
@ -90,142 +65,109 @@ export async function processImportAlbumTrack(input: {
album: ImportAlbum; album: ImportAlbum;
match: ImportAlbumMatch; match: ImportAlbumMatch;
}): Promise<ImportProcessPayload> { }): Promise<ImportProcessPayload> {
return assertSuccess( return readJson<ImportProcessPayload>(
await readJson<ImportProcessPayload>( apiClient.post('import/album/process', {
apiClient.post('import/album/process', { json: {
json: { album: input.album,
album: input.album, matches: [input.match],
matches: [input.match], },
}, }),
}),
),
'Failed to process album track',
); );
} }
export async function searchImportTracks(query: string): Promise<ImportTrackSearchPayload> { export async function searchImportTracks(query: string): Promise<ImportTrackSearchPayload> {
return assertSuccess( return readJson<ImportTrackSearchPayload>(
await readJson<ImportTrackSearchPayload>( apiClient.get('import/search/tracks', {
apiClient.get('import/search/tracks', { searchParams: {
searchParams: { q: query,
q: query, limit: '6',
limit: '6', },
}, }),
}),
),
'Track search failed',
); );
} }
export async function processImportSingleFile(file: unknown): Promise<ImportProcessPayload> { export async function processImportSingleFile(file: unknown): Promise<ImportProcessPayload> {
return assertSuccess( return readJson<ImportProcessPayload>(
await readJson<ImportProcessPayload>( apiClient.post('import/singles/process', {
apiClient.post('import/singles/process', { json: {
json: { files: [file],
files: [file], },
}, }),
}),
),
'Failed to process single',
); );
} }
export async function fetchAutoImportStatus(): Promise<ImportAutoImportStatusPayload> { export async function fetchAutoImportStatus(): Promise<ImportAutoImportStatusPayload> {
return assertSuccess( return readJson<ImportAutoImportStatusPayload>(apiClient.get('auto-import/status'));
await readJson<ImportAutoImportStatusPayload>(apiClient.get('auto-import/status')),
'Failed to load auto-import status',
);
} }
export async function fetchAutoImportSettings(): Promise<ImportAutoImportSettingsPayload> { export async function fetchAutoImportSettings(): Promise<ImportAutoImportSettingsPayload> {
return assertSuccess( return readJson<ImportAutoImportSettingsPayload>(apiClient.get('auto-import/settings'));
await readJson<ImportAutoImportSettingsPayload>(apiClient.get('auto-import/settings')),
'Failed to load auto-import settings',
);
} }
export async function saveAutoImportSettings(input: { export async function saveAutoImportSettings(input: {
confidenceThreshold: number; confidenceThreshold: number;
scanInterval: number; scanInterval: number;
}): Promise<void> { }): Promise<void> {
assertSuccess( await readJson<{ success: boolean; error?: string }>(
await readJson<{ success: boolean; error?: string }>( apiClient.post('auto-import/settings', {
apiClient.post('auto-import/settings', { json: {
json: { confidence_threshold: input.confidenceThreshold,
confidence_threshold: input.confidenceThreshold, scan_interval: input.scanInterval,
scan_interval: input.scanInterval, },
}, }),
}),
),
'Failed to save auto-import settings',
); );
} }
export async function fetchAutoImportResults(): Promise<ImportAutoImportResultsPayload> { export async function fetchAutoImportResults(): Promise<ImportAutoImportResultsPayload> {
return assertSuccess( return readJson<ImportAutoImportResultsPayload>(
await readJson<ImportAutoImportResultsPayload>( apiClient.get('auto-import/results', {
apiClient.get('auto-import/results', { searchParams: {
searchParams: { limit: '100',
limit: '100', },
}, }),
}),
),
'Failed to load auto-import results',
); );
} }
export async function toggleAutoImport(enabled: boolean): Promise<void> { export async function toggleAutoImport(enabled: boolean): Promise<void> {
assertSuccess( await readJson<{ success: boolean; error?: string }>(
await readJson<{ success: boolean; error?: string }>( apiClient.post('auto-import/toggle', {
apiClient.post('auto-import/toggle', { json: { enabled },
json: { enabled }, }),
}),
),
'Failed to toggle auto-import',
); );
} }
export async function triggerAutoImportScan(): Promise<void> { export async function triggerAutoImportScan(): Promise<void> {
assertSuccess( await readJson<{ success: boolean; error?: string }>(apiClient.post('auto-import/scan-now'));
await readJson<{ success: boolean; error?: string }>(apiClient.post('auto-import/scan-now')),
'Failed to trigger scan',
);
} }
export async function approveAutoImportResult(id: number): Promise<void> { export async function approveAutoImportResult(id: number): Promise<void> {
assertSuccess( const payload = await readJson<{ success: boolean; error?: string }>(
await readJson<{ success: boolean; error?: string }>( apiClient.post(`auto-import/approve/${id}`),
apiClient.post(`auto-import/approve/${id}`),
),
'Failed to approve import',
); );
if (!payload.success) {
throw new Error(payload.error || 'Failed to approve import');
}
} }
export async function rejectAutoImportResult(id: number): Promise<void> { export async function rejectAutoImportResult(id: number): Promise<void> {
assertSuccess( const payload = await readJson<{ success: boolean; error?: string }>(
await readJson<{ success: boolean; error?: string }>( apiClient.post(`auto-import/reject/${id}`),
apiClient.post(`auto-import/reject/${id}`),
),
'Failed to dismiss import',
); );
if (!payload.success) {
throw new Error(payload.error || 'Failed to dismiss import');
}
} }
export async function approveAllAutoImportResults(): Promise<number> { export async function approveAllAutoImportResults(): Promise<number> {
const payload = assertSuccess( const payload = await readJson<{ success: boolean; count?: number; error?: string }>(
await readJson<{ success: boolean; count?: number; error?: string }>( apiClient.post('auto-import/approve-all'),
apiClient.post('auto-import/approve-all'),
),
'Failed to approve imports',
); );
return payload.count ?? 0; return payload.count ?? 0;
} }
export async function clearCompletedAutoImportResults(): Promise<number> { export async function clearCompletedAutoImportResults(): Promise<number> {
const payload = assertSuccess( const payload = await readJson<{ success: boolean; count?: number; error?: string }>(
await readJson<{ success: boolean; count?: number; error?: string }>( apiClient.post('auto-import/clear-completed'),
apiClient.post('auto-import/clear-completed'),
),
'Failed to clear import history',
); );
return payload.count ?? 0; return payload.count ?? 0;
} }