mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
refactor(models): break the schema cycle that backup and restore sort through
print_archives.library_file_id -> library_files.folder_id -> library_folders.archive_id -> print_archives. Three nullable SET NULL links, each reasonable alone, that together made a loop metadata.sorted_tables could not sort: it dropped those edges, warned on every backup and every restore, and could return an order placing a child before its parent -- which once imported library_files ahead of library_folders and killed a restore on a ForeignKeyViolation. The restore no longer depends on that order (it strips every foreign key before importing and adds them back after), but the backup export sorts the same way, and the warning ends with "may raise an error in a future release" -- which would break backup and restore on one upgrade. Marking one edge use_alter removes it from the sort graph, not from the database: PostgreSQL emits it as ALTER TABLE ADD CONSTRAINT, as it already did for every constraint on these three tables, and SQLite inlines it into CREATE TABLE, so ON DELETE SET NULL holds on both. Verified against PostgreSQL 16 and SQLite.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Tests for api.importBackup's handling of a refused restore.
|
||||
*
|
||||
* A restore the server declines is an HTTPException, so its body is
|
||||
* `{detail}` — not the `{success, message}` a successful restore returns.
|
||||
* importBackup used to hand that body straight back, which left `success`
|
||||
* undefined (falsy, so the UI took the failure branch) and `message`
|
||||
* undefined with it: the operator got an empty error toast and no idea why.
|
||||
*
|
||||
* It matters most for exactly the case it was found in — a backup this version
|
||||
* cannot import. That refusal names the columns and both version numbers, and
|
||||
* all of it was being dropped on the floor.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
const server = setupServer();
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
const backupFile = () => new File(['(a backup)'], 'backup.zip', { type: 'application/zip' });
|
||||
|
||||
describe('api.importBackup', () => {
|
||||
it('passes a successful restore through unchanged', async () => {
|
||||
server.use(
|
||||
http.post('*/settings/restore', () =>
|
||||
HttpResponse.json({ success: true, message: 'Backup restored successfully.' })
|
||||
)
|
||||
);
|
||||
|
||||
const result = await api.importBackup(backupFile());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('Backup restored successfully.');
|
||||
});
|
||||
|
||||
it('turns a refusal into a failure carrying the reason', async () => {
|
||||
const detail =
|
||||
'This backup cannot be restored by this version of Bambuddy. It carries no value for ' +
|
||||
'1 column(s) this version requires and cannot default: cost_centers.name.';
|
||||
server.use(http.post('*/settings/restore', () => HttpResponse.json({ detail }, { status: 400 })));
|
||||
|
||||
const result = await api.importBackup(backupFile());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe(detail);
|
||||
});
|
||||
|
||||
it('reports a failure even when the error body is not JSON', async () => {
|
||||
server.use(
|
||||
http.post('*/settings/restore', () => new HttpResponse('upstream exploded', { status: 502 }))
|
||||
);
|
||||
|
||||
const result = await api.importBackup(backupFile());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -5759,10 +5759,23 @@ export const api = {
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
return response.json() as Promise<{
|
||||
const data = (await response.json().catch(() => null)) as {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
detail?: string;
|
||||
} | null;
|
||||
// A refused restore is an HTTPException, so the body is {detail}, not
|
||||
// {success, message}. Returning it unmapped made `success` undefined and
|
||||
// `message` undefined too — the modal then raised an empty error toast,
|
||||
// which is the one case where the reason matters most (e.g. a backup this
|
||||
// version cannot import names the columns and both versions).
|
||||
if (!response.ok) {
|
||||
return { success: false, message: data?.detail ?? data?.message ?? '' };
|
||||
}
|
||||
return (data ?? { success: false, message: '' }) as {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}>;
|
||||
};
|
||||
},
|
||||
checkFfmpeg: () =>
|
||||
request<{ installed: boolean; path: string | null }>('/settings/check-ffmpeg'),
|
||||
|
||||
@@ -1532,7 +1532,7 @@ export function GitHubBackupSettings() {
|
||||
if (result.success) {
|
||||
showToast(t('backup.backupRestoredRestart'), 'success');
|
||||
} else {
|
||||
showToast(result.message, 'error');
|
||||
showToast(result.message || t('backup.failedToRestore'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : t('backup.failedToRestore');
|
||||
|
||||
Reference in New Issue
Block a user