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