mirror of
https://github.com/debpalash/VoiceStudio.git
synced 2026-10-02 01:26:35 +08:00
Merge PR #2488: commit profile deletion before removing assets
This commit is contained in:
@@ -86,6 +86,9 @@ metadata and the backend fallback mirror it.
|
||||
- The Twilio guide and integration directory describe the guided setup and in-app integration pages (#2304)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Keep later saved-voice deletion checks moving after stalled confirmations (#2488) — thanks @rudycelekli!
|
||||
- Preserve voice assets when profile deletion cannot commit and report incomplete cleanup (#2483) — thanks @rudycelekli!
|
||||
- Dub assembly, cached segments and audio tools can read generated WAV files when TorchCodec is missing or cannot load (#2379) — thanks @tokutei58301-boop!
|
||||
- Remote API-key and share-PIN clients can record and read export history while native filesystem operations stay local (#2383, #2384) — thanks @sedatdagg!
|
||||
- Concurrent job events receive unique sequence numbers (#2384) — thanks @sedatdagg!
|
||||
|
||||
@@ -15,6 +15,7 @@ from pydantic import BaseModel
|
||||
from core.db import db_conn
|
||||
from core.config import VOICES_DIR, OUTPUTS_DIR
|
||||
from core import event_bus
|
||||
from core.scrub import scrub_text
|
||||
from core.personalities import get_personalities
|
||||
from omnivoice.utils.voice_design import heal_design_instruct, sanitize_instruct
|
||||
from core.path_security import UnsafePath, resolve_within
|
||||
@@ -939,19 +940,44 @@ def revoke_consent(profile_id: str):
|
||||
|
||||
@router.delete("/profiles/{profile_id}")
|
||||
def delete_profile(profile_id: str):
|
||||
paths = []
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT ref_audio_path, locked_audio_path, consent_audio_path FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
if row:
|
||||
for col in ["ref_audio_path", "locked_audio_path", "consent_audio_path"]:
|
||||
if row[col]:
|
||||
path = _voices_path(row[col])
|
||||
if path and os.path.exists(path):
|
||||
os.remove(path)
|
||||
if path:
|
||||
paths.append(path)
|
||||
portrait_path = _voices_path(f"{profile_id}.portrait.jpg")
|
||||
if portrait_path and os.path.isfile(portrait_path):
|
||||
os.remove(portrait_path)
|
||||
# Prevent FOREIGN KEY constraint failure
|
||||
paths.append(portrait_path)
|
||||
# Commit the database change before removing assets: a failed write or
|
||||
# commit must leave the rolled-back profile's files usable.
|
||||
conn.execute("UPDATE generation_history SET profile_id = NULL WHERE profile_id=?", (profile_id,))
|
||||
conn.execute("DELETE FROM voice_profiles WHERE id=?", (profile_id,))
|
||||
failed_assets = []
|
||||
for path in dict.fromkeys(paths):
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
# A cleanup failure cannot roll back the committed deletion, and
|
||||
# must not prevent cleanup of the profile's remaining assets.
|
||||
try:
|
||||
failed_assets.append(os.path.relpath(
|
||||
resolve_within(VOICES_DIR, path), os.path.realpath(VOICES_DIR)
|
||||
))
|
||||
except (UnsafePath, OSError, ValueError):
|
||||
failed_assets.append("asset location unavailable (consult local backend log)")
|
||||
logger.warning("Deleted profile asset cleanup failed: %s", scrub_text(path))
|
||||
event_bus.emit("profiles", {"action": "deleted", "id": profile_id})
|
||||
if failed_assets:
|
||||
raise HTTPException(status_code=500, detail=(
|
||||
"The profile record was deleted, but asset cleanup is incomplete for: "
|
||||
+ ", ".join(failed_assets)
|
||||
+ ". Paths are relative to the voices folder; remove the files manually. "
|
||||
"Consult the local backend log if an asset location is unavailable."
|
||||
))
|
||||
return {"deleted": profile_id}
|
||||
|
||||
@@ -120,3 +120,11 @@ Script import supports TXT, Markdown, DOC, DOCX, PDF and EPUB (text documents,
|
||||
not scanned-image OCR). Paste inserts at the caret; Replace script offers Undo.
|
||||
Clicking in the script shows the expression picker near the caret without taking
|
||||
typing focus. The voice chooser and save-profile form share the editor width.
|
||||
|
||||
Deleting a saved voice commits the profile and history changes before removing its reference, locked take, consent recording and portrait. A failed database transaction leaves those assets available. Cleanup after a successful deletion is best effort; one unavailable asset does not prevent cleanup of the others.
|
||||
|
||||
If cleanup fails after the profile record is deleted, the response reports incomplete cleanup and names the remaining confined paths relative to the voices folder, including nested locations. If a location can no longer be confined, the response directs the operator to the local backend log instead of exposing a host path. The backend log records their local locations for manual cleanup with home-directory prefixes redacted to `~`. Electron refreshes the saved-voice list after an error and clears selected clone/design references only when that list confirms the profile is gone; the existing error message still reports incomplete asset cleanup. Deleting the missing profile again does not retry its former audio files; no automatic cleanup retry is promised.
|
||||
|
||||
If the backend becomes unavailable during a deletion error, the error settles immediately while confirmation waits in the managed saved-voice query. Backend readiness resumes that query. Only a successful list confirming absence clears the selected voice; rollback, failed confirmation and query cancellation preserve it. This refresh does not retry asset cleanup.
|
||||
|
||||
Errors during batch deletion are each checked in order, so a later error cannot discard an earlier deletion check. A confirmation request stalled for 30 seconds is cancelled so later checks can continue; the timed-out check preserves its voice selection because absence remains unknown. Time paused offline does not count toward this request limit. Clearing the query-client cache cancels checks from that earlier cache lifecycle.
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider, onlineManager } from '@tanstack/react-query';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import type { Profile } from '@/lib/api/types';
|
||||
import { queryKeys } from '@/lib/query';
|
||||
import { cloneSettingsStore, patchCloneSettings } from '@/lib/store/clone-settings';
|
||||
|
||||
const mock = vi.hoisted(() => ({ replace: vi.fn() }));
|
||||
const mock = vi.hoisted(() => ({
|
||||
replace: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
list: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/lib/api/profiles', () => ({
|
||||
createCloneProfile: vi.fn(),
|
||||
deleteProfile: vi.fn(),
|
||||
listProfiles: vi.fn(),
|
||||
deleteProfile: mock.remove,
|
||||
listProfiles: mock.list,
|
||||
replaceProfileAudio: mock.replace,
|
||||
}));
|
||||
vi.mock('./use-backend-status', () => ({ useBackendStatus: () => ({ stage: 'ready' }) }));
|
||||
import { useReplaceProfileAudio } from './use-profiles';
|
||||
import { useDeleteProfile, useProfiles, useReplaceProfileAudio } from './use-profiles';
|
||||
import { readDraft, writeDraft } from '@/features/design/design-draft';
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: mock.error, success: mock.success } }));
|
||||
vi.mock('@/lib/i18n-text', () => ({
|
||||
tr: (key: string, options?: { message?: string }) => `${key}: ${options?.message ?? ''}`,
|
||||
}));
|
||||
|
||||
const profile = (patch: Partial<Profile>): Profile =>
|
||||
({ id: 'v1', name: 'Voice', kind: 'clone', ref_text: 'old words', ...patch }) as Profile;
|
||||
@@ -32,8 +44,132 @@ function setup() {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
onlineManager.setOnline(true);
|
||||
vi.clearAllMocks();
|
||||
patchCloneSettings({ selectedProfileId: null, refText: '' });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it.each(['same hook', 'separate hooks'])(
|
||||
'continues queued deletion confirmation after an earlier request stalls: %s',
|
||||
async (hookMode) => {
|
||||
const stalled = deferred<Profile[]>();
|
||||
mock.remove.mockRejectedValue(new Error('cleanup incomplete'));
|
||||
mock.list.mockReturnValueOnce(stalled.promise).mockResolvedValue([]);
|
||||
patchCloneSettings({ selectedProfileId: 'v2' });
|
||||
writeDraft({ ...readDraft(), profileId: 'v2' });
|
||||
const { client, result, wrapper } = setupDelete();
|
||||
const second =
|
||||
hookMode === 'separate hooks'
|
||||
? renderHook(() => useDeleteProfile(), { wrapper }).result
|
||||
: result;
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync('v1')).rejects.toThrow('cleanup incomplete');
|
||||
await expect(second.current.mutateAsync('v2')).rejects.toThrow('cleanup incomplete');
|
||||
});
|
||||
expect(mock.list).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(29_999);
|
||||
});
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBe('v2');
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
expect(mock.list).toHaveBeenCalledTimes(2);
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBeNull();
|
||||
expect(readDraft().profileId).toBeNull();
|
||||
expect(client.getQueryData(queryKeys.profiles)).toEqual([]);
|
||||
|
||||
// A cancelled transport that ignores abort cannot overwrite a newer list
|
||||
// or clear a voice whose absence the timed-out request never established.
|
||||
patchCloneSettings({ selectedProfileId: 'v1' });
|
||||
writeDraft({ ...readDraft(), profileId: 'v1' });
|
||||
await act(async () => {
|
||||
stalled.resolve([profile({ id: 'v2' })]);
|
||||
});
|
||||
expect(client.getQueryData(queryKeys.profiles)).toEqual([]);
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBe('v1');
|
||||
expect(readDraft().profileId).toBe('v1');
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('does not expire queued confirmation while offline longer than its request deadline', async () => {
|
||||
const responses = deferred<void>();
|
||||
mock.remove.mockImplementation(async () => {
|
||||
await responses.promise;
|
||||
throw new Error('backend stopped after DELETE');
|
||||
});
|
||||
mock.list.mockResolvedValue([]);
|
||||
patchCloneSettings({ selectedProfileId: 'v1' });
|
||||
writeDraft({ ...readDraft(), profileId: 'v2' });
|
||||
const { client, result, wrapper } = setupDelete();
|
||||
const second = renderHook(() => useDeleteProfile(), { wrapper });
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await act(async () => {
|
||||
const first = result.current.mutateAsync('v1').catch((error) => error);
|
||||
const next = second.result.current.mutateAsync('v2').catch((error) => error);
|
||||
await Promise.resolve();
|
||||
onlineManager.setOnline(false);
|
||||
responses.resolve();
|
||||
await Promise.all([first, next]);
|
||||
});
|
||||
expect(client.getQueryState(queryKeys.profiles)?.fetchStatus).toBe('paused');
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
});
|
||||
expect(mock.list).not.toHaveBeenCalled();
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBe('v1');
|
||||
await act(async () => {
|
||||
onlineManager.setOnline(true);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(mock.list).toHaveBeenCalledTimes(2);
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBeNull();
|
||||
expect(readDraft().profileId).toBeNull();
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it('bounds confirmation when it joins a refresh already fetching after cancellation', async () => {
|
||||
const stalled = deferred<Profile[]>();
|
||||
mock.remove.mockRejectedValue(new Error('cleanup incomplete'));
|
||||
mock.list.mockReturnValueOnce(stalled.promise).mockResolvedValue([]);
|
||||
patchCloneSettings({ selectedProfileId: 'v2' });
|
||||
writeDraft({ ...readDraft(), profileId: 'v2' });
|
||||
const { client, result } = setupDelete();
|
||||
const cancelQueries = client.cancelQueries.bind(client);
|
||||
const cancel = vi.spyOn(client, 'cancelQueries').mockImplementationOnce((...args) => {
|
||||
const cancelled = cancelQueries(...args);
|
||||
void cancelled.then(() => {
|
||||
void client.fetchQuery({ queryKey: queryKeys.profiles, queryFn: mock.list }).catch(() => {});
|
||||
});
|
||||
return cancelled;
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync('v1')).rejects.toThrow('cleanup incomplete');
|
||||
await expect(result.current.mutateAsync('v2')).rejects.toThrow('cleanup incomplete');
|
||||
});
|
||||
expect(mock.list).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
});
|
||||
expect(mock.list).toHaveBeenCalledTimes(2);
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBeNull();
|
||||
expect(readDraft().profileId).toBeNull();
|
||||
} finally {
|
||||
client.clear();
|
||||
cancel.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('writes the replaced clip back into the cached profile and the selected transcript', async () => {
|
||||
@@ -61,3 +197,467 @@ it('leaves the composer alone when another voice is selected', async () => {
|
||||
|
||||
expect(cloneSettingsStore.state.refText).toBe('keep me');
|
||||
});
|
||||
|
||||
function setupDelete(seedProfiles = true) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
if (seedProfiles) {
|
||||
client.setQueryData<Profile[]>(queryKeys.profiles, [profile({}), profile({ id: 'v2' })]);
|
||||
}
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
return { client, wrapper, ...renderHook(() => useDeleteProfile(), { wrapper }) };
|
||||
}
|
||||
|
||||
it('clears deleted clone and design references after cleanup fails and the list confirms deletion', async () => {
|
||||
const detail = 'The profile record was deleted, but asset cleanup is incomplete for: ref.wav.';
|
||||
mock.remove.mockRejectedValue(new Error(detail));
|
||||
mock.list.mockResolvedValue([profile({ id: 'v2' })]);
|
||||
patchCloneSettings({ selectedProfileId: 'v1' });
|
||||
writeDraft({ ...readDraft(), profileId: 'v1' });
|
||||
const { client, result } = setupDelete();
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync('v1')).rejects.toThrow(detail);
|
||||
});
|
||||
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBeNull();
|
||||
expect(readDraft().profileId).toBeNull();
|
||||
expect(client.getQueryData<Profile[]>(queryKeys.profiles)?.map((p) => p.id)).toEqual(['v2']);
|
||||
expect(mock.error).toHaveBeenCalledWith(expect.stringContaining(detail));
|
||||
expect(mock.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['rollback', 'unreachable'])(
|
||||
'preserves selected voices when deletion cannot be confirmed: %s',
|
||||
async (failure) => {
|
||||
mock.remove.mockRejectedValue(new Error('database commit failed'));
|
||||
if (failure === 'rollback') mock.list.mockResolvedValue([profile({}), profile({ id: 'v2' })]);
|
||||
else mock.list.mockRejectedValue(new Error('backend unavailable'));
|
||||
patchCloneSettings({ selectedProfileId: 'v1' });
|
||||
writeDraft({ ...readDraft(), profileId: 'v1' });
|
||||
const { client, result } = setupDelete();
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync('v1')).rejects.toThrow('database commit failed');
|
||||
});
|
||||
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBe('v1');
|
||||
expect(readDraft().profileId).toBe('v1');
|
||||
expect(client.getQueryData<Profile[]>(queryKeys.profiles)?.map((p) => p.id)).toEqual([
|
||||
'v1',
|
||||
'v2',
|
||||
]);
|
||||
expect(mock.error).toHaveBeenCalledWith(expect.stringContaining('database commit failed'));
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves another selected voice after a confirmed partial deletion', async () => {
|
||||
mock.remove.mockRejectedValue(new Error('cleanup incomplete'));
|
||||
mock.list.mockResolvedValue([profile({ id: 'v2' })]);
|
||||
patchCloneSettings({ selectedProfileId: 'v2' });
|
||||
writeDraft({ ...readDraft(), profileId: 'v2' });
|
||||
const { result } = setupDelete();
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync('v1')).rejects.toThrow('cleanup incomplete');
|
||||
});
|
||||
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBe('v2');
|
||||
expect(readDraft().profileId).toBe('v2');
|
||||
});
|
||||
|
||||
it('clears selected voices after an ordinary successful deletion', async () => {
|
||||
mock.remove.mockResolvedValue(undefined);
|
||||
patchCloneSettings({ selectedProfileId: 'v1' });
|
||||
writeDraft({ ...readDraft(), profileId: 'v1' });
|
||||
const { result } = setupDelete();
|
||||
|
||||
await act(() => result.current.mutateAsync('v1'));
|
||||
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBeNull();
|
||||
expect(readDraft().profileId).toBeNull();
|
||||
expect(mock.success).toHaveBeenCalled();
|
||||
expect(mock.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
it('does not overwrite a newer query refresh with an older deletion-error list', async () => {
|
||||
const stale = deferred<Profile[]>();
|
||||
mock.remove.mockRejectedValue(new Error('cleanup incomplete'));
|
||||
mock.list.mockReturnValueOnce(stale.promise);
|
||||
patchCloneSettings({ selectedProfileId: 'v1' });
|
||||
writeDraft({ ...readDraft(), profileId: 'v1' });
|
||||
const { client, result } = setupDelete();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
renderHook(() => useProfiles(), { wrapper });
|
||||
let deletion!: Promise<unknown>;
|
||||
await act(async () => {
|
||||
deletion = result.current.mutateAsync('v1').catch((error) => error);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => expect(mock.list).toHaveBeenCalledTimes(1));
|
||||
const updated = profile({ id: 'v2', name: 'Updated voice' });
|
||||
const created = profile({ id: 'v3', name: 'New voice' });
|
||||
mock.list.mockResolvedValueOnce([updated, created]);
|
||||
await act(() => client.invalidateQueries({ queryKey: queryKeys.profiles }));
|
||||
expect(client.getQueryData<Profile[]>(queryKeys.profiles)).toEqual([updated, created]);
|
||||
await act(async () => {
|
||||
stale.resolve([profile({ id: 'v2' })]);
|
||||
await deletion;
|
||||
});
|
||||
expect(client.getQueryData<Profile[]>(queryKeys.profiles)).toEqual([updated, created]);
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBeNull();
|
||||
expect(readDraft().profileId).toBeNull();
|
||||
});
|
||||
|
||||
it('cancels an older profile query before confirming deletion after an error', async () => {
|
||||
const stale = deferred<Profile[]>();
|
||||
mock.list.mockReturnValueOnce(stale.promise).mockResolvedValueOnce([profile({ id: 'v2' })]);
|
||||
mock.remove.mockRejectedValue(new Error('cleanup incomplete'));
|
||||
patchCloneSettings({ selectedProfileId: 'v1' });
|
||||
writeDraft({ ...readDraft(), profileId: 'v1' });
|
||||
const { client, result } = setupDelete();
|
||||
const older = client
|
||||
.fetchQuery({ queryKey: queryKeys.profiles, queryFn: mock.list, staleTime: 0 })
|
||||
.catch((error) => error);
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync('v1')).rejects.toThrow('cleanup incomplete');
|
||||
});
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBeNull();
|
||||
expect(readDraft().profileId).toBeNull();
|
||||
await act(async () => {
|
||||
stale.resolve([profile({}), profile({ id: 'v2' })]);
|
||||
await older;
|
||||
});
|
||||
expect(client.getQueryData<Profile[]>(queryKeys.profiles)?.map((p) => p.id)).toEqual(['v2']);
|
||||
});
|
||||
|
||||
it('settles a deletion error while the native backend is offline and preserves selections', async () => {
|
||||
mock.remove.mockImplementation(async () => {
|
||||
onlineManager.setOnline(false);
|
||||
throw new Error('backend offline');
|
||||
});
|
||||
mock.list.mockRejectedValue(new Error('backend offline'));
|
||||
patchCloneSettings({ selectedProfileId: 'v1' });
|
||||
writeDraft({ ...readDraft(), profileId: 'v1' });
|
||||
const { result } = setupDelete();
|
||||
let deletion!: Promise<unknown>;
|
||||
try {
|
||||
await act(async () => {
|
||||
deletion = result.current.mutateAsync('v1').catch((error) => error);
|
||||
await Promise.resolve();
|
||||
});
|
||||
const settled = await Promise.race([
|
||||
deletion.then(() => true),
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 100)),
|
||||
]);
|
||||
expect(settled).toBe(true);
|
||||
await waitFor(() => expect(result.current.isPending).toBe(false));
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBe('v1');
|
||||
expect(readDraft().profileId).toBe('v1');
|
||||
expect(mock.list).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
onlineManager.setOnline(true);
|
||||
await act(async () => {
|
||||
await deletion;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['deleted', 'rollback', 'unreachable'])(
|
||||
'reconciles a deletion error after backend readiness without assuming deletion: %s',
|
||||
async (serverOutcome) => {
|
||||
mock.remove.mockImplementation(async () => {
|
||||
onlineManager.setOnline(false);
|
||||
throw new Error('backend stopped after DELETE');
|
||||
});
|
||||
if (serverOutcome === 'deleted') mock.list.mockResolvedValue([profile({ id: 'v2' })]);
|
||||
else if (serverOutcome === 'rollback') {
|
||||
mock.list.mockResolvedValue([profile({}), profile({ id: 'v2' })]);
|
||||
} else mock.list.mockRejectedValue(new Error('backend still unavailable'));
|
||||
patchCloneSettings({ selectedProfileId: 'v1' });
|
||||
writeDraft({ ...readDraft(), profileId: 'v1' });
|
||||
const { client, result } = setupDelete();
|
||||
try {
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync('v1')).rejects.toThrow(
|
||||
'backend stopped after DELETE',
|
||||
);
|
||||
});
|
||||
await waitFor(() => expect(result.current.isPending).toBe(false));
|
||||
expect(mock.list).not.toHaveBeenCalled();
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBe('v1');
|
||||
expect(readDraft().profileId).toBe('v1');
|
||||
|
||||
await act(async () => {
|
||||
onlineManager.setOnline(true);
|
||||
});
|
||||
await waitFor(() => expect(mock.list).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() =>
|
||||
expect(client.getQueryState(queryKeys.profiles)?.fetchStatus).toBe('idle'),
|
||||
);
|
||||
const expectedSelection = serverOutcome === 'deleted' ? null : 'v1';
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBe(expectedSelection);
|
||||
expect(readDraft().profileId).toBe(expectedSelection);
|
||||
expect(client.getQueryData<Profile[]>(queryKeys.profiles)?.map((p) => p.id)).toEqual(
|
||||
serverOutcome === 'deleted' ? ['v2'] : ['v1', 'v2'],
|
||||
);
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['paused', 'fetching'])(
|
||||
'cancels managed confirmation on query-client clear without applying a stale result after remount: %s',
|
||||
async (confirmationState) => {
|
||||
const stale = deferred<Profile[]>();
|
||||
mock.remove.mockImplementation(async () => {
|
||||
if (confirmationState === 'paused') onlineManager.setOnline(false);
|
||||
throw new Error('cleanup incomplete');
|
||||
});
|
||||
mock.list.mockReturnValue(stale.promise);
|
||||
patchCloneSettings({ selectedProfileId: 'v1' });
|
||||
writeDraft({ ...readDraft(), profileId: 'v1' });
|
||||
const { client, result, unmount } = setupDelete();
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync('v1')).rejects.toThrow('cleanup incomplete');
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(client.getQueryState(queryKeys.profiles)?.fetchStatus).toBe(confirmationState),
|
||||
);
|
||||
unmount();
|
||||
client.clear();
|
||||
const currentProfiles = [profile({}), profile({ id: 'v2', name: 'Fresh cached voice' })];
|
||||
client.setQueryData(queryKeys.profiles, currentProfiles);
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
const remounted = renderHook(() => useProfiles(), { wrapper });
|
||||
try {
|
||||
await act(async () => {
|
||||
onlineManager.setOnline(true);
|
||||
stale.resolve([profile({ id: 'v2', name: 'Stale deletion result' })]);
|
||||
});
|
||||
expect(mock.list).toHaveBeenCalledTimes(confirmationState === 'paused' ? 0 : 1);
|
||||
expect(client.getQueryData(queryKeys.profiles)).toEqual(currentProfiles);
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBe('v1');
|
||||
expect(readDraft().profileId).toBe('v1');
|
||||
} finally {
|
||||
remounted.unmount();
|
||||
client.clear();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['same-hook', 'separate-hooks'])(
|
||||
'confirms both failed batch deletions without losing the first selected voice: %s',
|
||||
async (hookLayout) => {
|
||||
const firstConfirmation = deferred<Profile[]>();
|
||||
mock.remove.mockRejectedValue(new Error('cleanup incomplete'));
|
||||
mock.list.mockReturnValueOnce(firstConfirmation.promise).mockResolvedValueOnce([]);
|
||||
patchCloneSettings({ selectedProfileId: 'v1' });
|
||||
writeDraft({ ...readDraft(), profileId: 'v2' });
|
||||
const { client, result, wrapper } = setupDelete();
|
||||
const second =
|
||||
hookLayout === 'separate-hooks'
|
||||
? renderHook(() => useDeleteProfile(), { wrapper }).result
|
||||
: result;
|
||||
try {
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync('v1')).rejects.toThrow('cleanup incomplete');
|
||||
});
|
||||
await waitFor(() => expect(mock.list).toHaveBeenCalledTimes(1));
|
||||
await act(async () => {
|
||||
await expect(second.current.mutateAsync('v2')).rejects.toThrow('cleanup incomplete');
|
||||
});
|
||||
await act(async () => {
|
||||
firstConfirmation.resolve([profile({ id: 'v2' })]);
|
||||
});
|
||||
await waitFor(() => expect(mock.list).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() =>
|
||||
expect(client.getQueryState(queryKeys.profiles)?.fetchStatus).toBe('idle'),
|
||||
);
|
||||
expect(client.getQueryData<Profile[]>(queryKeys.profiles)).toEqual([]);
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBeNull();
|
||||
expect(readDraft().profileId).toBeNull();
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('settles two in-flight DELETE errors offline and confirms both after reconnect across hooks', async () => {
|
||||
const responses = deferred<void>();
|
||||
mock.remove.mockImplementation(async () => {
|
||||
await responses.promise;
|
||||
throw new Error('backend stopped after DELETE');
|
||||
});
|
||||
mock.list.mockResolvedValue([]);
|
||||
patchCloneSettings({ selectedProfileId: 'v1' });
|
||||
writeDraft({ ...readDraft(), profileId: 'v2' });
|
||||
const { client, result, wrapper } = setupDelete();
|
||||
const second = renderHook(() => useDeleteProfile(), { wrapper });
|
||||
let firstError!: Promise<unknown>;
|
||||
let secondError!: Promise<unknown>;
|
||||
try {
|
||||
await act(async () => {
|
||||
firstError = result.current.mutateAsync('v1').catch((error) => error);
|
||||
secondError = second.result.current.mutateAsync('v2').catch((error) => error);
|
||||
});
|
||||
await waitFor(() => expect(mock.remove).toHaveBeenCalledTimes(2));
|
||||
await act(async () => {
|
||||
onlineManager.setOnline(false);
|
||||
responses.resolve();
|
||||
await Promise.all([firstError, secondError]);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.isPending).toBe(false);
|
||||
expect(second.result.current.isPending).toBe(false);
|
||||
});
|
||||
expect(mock.list).not.toHaveBeenCalled();
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBe('v1');
|
||||
expect(readDraft().profileId).toBe('v2');
|
||||
|
||||
await act(async () => {
|
||||
onlineManager.setOnline(true);
|
||||
});
|
||||
await waitFor(() => expect(mock.list).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => {
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBeNull();
|
||||
expect(readDraft().profileId).toBeNull();
|
||||
});
|
||||
expect(client.getQueryData(queryKeys.profiles)).toEqual([]);
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'discards a queued confirmation on cache clear while the first is paused (initial query: %s)',
|
||||
async (seedProfiles) => {
|
||||
const responses = deferred<void>();
|
||||
mock.remove.mockImplementation(async () => {
|
||||
await responses.promise;
|
||||
throw new Error('backend stopped after DELETE');
|
||||
});
|
||||
mock.list.mockResolvedValue([]);
|
||||
patchCloneSettings({ selectedProfileId: 'v1' });
|
||||
writeDraft({ ...readDraft(), profileId: 'v2' });
|
||||
const { client, result, wrapper, unmount } = setupDelete(seedProfiles);
|
||||
const second = renderHook(() => useDeleteProfile(), { wrapper });
|
||||
let firstError!: Promise<unknown>;
|
||||
let secondError!: Promise<unknown>;
|
||||
await act(async () => {
|
||||
firstError = result.current.mutateAsync('v1').catch((error) => error);
|
||||
secondError = second.result.current.mutateAsync('v2').catch((error) => error);
|
||||
});
|
||||
await waitFor(() => expect(mock.remove).toHaveBeenCalledTimes(2));
|
||||
await act(async () => {
|
||||
onlineManager.setOnline(false);
|
||||
responses.resolve();
|
||||
await Promise.all([firstError, secondError]);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(client.getQueryState(queryKeys.profiles)?.fetchStatus).toBe('paused'),
|
||||
);
|
||||
unmount();
|
||||
second.unmount();
|
||||
client.clear();
|
||||
const currentProfiles = [profile({}), profile({ id: 'v2' })];
|
||||
client.setQueryData(queryKeys.profiles, currentProfiles);
|
||||
const remounted = renderHook(() => useProfiles(), { wrapper });
|
||||
try {
|
||||
await act(async () => {
|
||||
onlineManager.setOnline(true);
|
||||
});
|
||||
expect(mock.list).not.toHaveBeenCalled();
|
||||
expect(client.getQueryData(queryKeys.profiles)).toEqual(currentProfiles);
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBe('v1');
|
||||
expect(readDraft().profileId).toBe('v2');
|
||||
} finally {
|
||||
remounted.unmount();
|
||||
client.clear();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('confirms deletion when a live query client has no loaded profiles query', async () => {
|
||||
mock.remove.mockRejectedValue(new Error('cleanup incomplete'));
|
||||
mock.list.mockResolvedValue([]);
|
||||
patchCloneSettings({ selectedProfileId: 'v1' });
|
||||
writeDraft({ ...readDraft(), profileId: 'v1' });
|
||||
const { client, result } = setupDelete(false);
|
||||
try {
|
||||
expect(client.getQueryState(queryKeys.profiles)).toBeUndefined();
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync('v1')).rejects.toThrow('cleanup incomplete');
|
||||
});
|
||||
await waitFor(() => expect(mock.list).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => {
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBeNull();
|
||||
expect(readDraft().profileId).toBeNull();
|
||||
});
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses normal query-client defaults and identity without fetching while offline', async () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 60_000,
|
||||
queryKeyHashFn: (key) => `custom:${JSON.stringify(key)}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
mock.remove.mockImplementation(async () => {
|
||||
onlineManager.setOnline(false);
|
||||
throw new Error('backend offline');
|
||||
});
|
||||
mock.list.mockResolvedValue([]);
|
||||
patchCloneSettings({ selectedProfileId: 'v1' });
|
||||
writeDraft({ ...readDraft(), profileId: 'v1' });
|
||||
const { result } = renderHook(() => useDeleteProfile(), { wrapper });
|
||||
try {
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync('v1')).rejects.toThrow('backend offline');
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(client.getQueryState(queryKeys.profiles)?.fetchStatus).toBe('paused'),
|
||||
);
|
||||
expect(client.getQueryCache().getAll()).toHaveLength(1);
|
||||
expect(client.getQueryCache().find({ queryKey: queryKeys.profiles })?.queryHash).toBe(
|
||||
`custom:${JSON.stringify(queryKeys.profiles)}`,
|
||||
);
|
||||
expect(mock.list).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
onlineManager.setOnline(true);
|
||||
});
|
||||
await waitFor(() => expect(mock.list).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => {
|
||||
expect(cloneSettingsStore.state.selectedProfileId).toBeNull();
|
||||
expect(readDraft().profileId).toBeNull();
|
||||
});
|
||||
expect(client.getQueryCache().getAll()).toHaveLength(1);
|
||||
expect(client.getQueryData(queryKeys.profiles)).toEqual([]);
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
type UseMutationResult,
|
||||
type UseQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
@@ -27,6 +28,8 @@ import { readDraft, writeDraft } from '@/features/design/design-draft';
|
||||
import { useBackendStatus } from './use-backend-status';
|
||||
|
||||
const PROFILES_STALE_MS = 30_000;
|
||||
const DELETION_CONFIRMATION_TIMEOUT_MS = 30_000;
|
||||
const deletionConfirmations = new WeakMap<QueryClient, Promise<void>>();
|
||||
|
||||
export function useProfiles(): UseQueryResult<Profile[]> {
|
||||
const status = useBackendStatus();
|
||||
@@ -88,22 +91,94 @@ export function useReplaceProfileAudio(): UseMutationResult<
|
||||
|
||||
export function useDeleteProfile(): UseMutationResult<void, Error, string> {
|
||||
const queryClient = useQueryClient();
|
||||
const forgetProfile = (id: string) => {
|
||||
// The form must not keep pointing at a voice that no longer exists.
|
||||
if (cloneSettingsStore.state.selectedProfileId === id) {
|
||||
setCloneSetting('selectedProfileId', null);
|
||||
}
|
||||
const designDraft = readDraft();
|
||||
if (designDraft.profileId === id) {
|
||||
writeDraft({ ...designDraft, profileId: null });
|
||||
}
|
||||
};
|
||||
return useMutation({
|
||||
mutationFn: deleteProfile,
|
||||
onSuccess: (_result, id) => {
|
||||
// The form must not keep pointing at a voice that no longer exists.
|
||||
if (cloneSettingsStore.state.selectedProfileId === id) {
|
||||
setCloneSetting('selectedProfileId', null);
|
||||
}
|
||||
const designDraft = readDraft();
|
||||
if (designDraft.profileId === id) {
|
||||
writeDraft({ ...designDraft, profileId: null });
|
||||
}
|
||||
forgetProfile(id);
|
||||
toast.success(tr('clone.profile_deleted'));
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.profiles });
|
||||
},
|
||||
onError: (err) => {
|
||||
onError: (err, id) => {
|
||||
toast.error(tr('clone.delete_profile_failed', { message: describeError(err) }));
|
||||
// Let the deletion error settle even if confirmation is paused offline.
|
||||
// The managed profiles query resumes when the backend becomes ready.
|
||||
const originalQuery = queryClient
|
||||
.getQueryCache()
|
||||
.build(queryClient, queryClient.defaultQueryOptions({ queryKey: queryKeys.profiles }));
|
||||
const previous = deletionConfirmations.get(queryClient) ?? Promise.resolve();
|
||||
// A later batch deletion must not cancel an earlier confirmation.
|
||||
const confirmation = previous.then(async () => {
|
||||
try {
|
||||
// Discard queued checks from a cleared/replaced query-client cache.
|
||||
if (
|
||||
queryClient.getQueryCache().find({ queryKey: queryKeys.profiles }) !== originalQuery
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Asset cleanup can fail after the profile deletion has committed.
|
||||
// Confirm absence before clearing state; a rollback or unreachable
|
||||
// backend must preserve the user's selected voice.
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.profiles });
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const clearTimer = () => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
timer = undefined;
|
||||
};
|
||||
const trackRequest = () => {
|
||||
if (originalQuery.state.fetchStatus !== 'fetching') {
|
||||
clearTimer();
|
||||
} else if (timer === undefined) {
|
||||
timer = setTimeout(() => {
|
||||
// Cancel this lifecycle only; a cleared cache may contain a
|
||||
// replacement profiles query. Unknown absence keeps selections.
|
||||
if (
|
||||
queryClient.getQueryCache().find({ queryKey: queryKeys.profiles }) ===
|
||||
originalQuery &&
|
||||
originalQuery.state.fetchStatus === 'fetching'
|
||||
) {
|
||||
void originalQuery.cancel();
|
||||
}
|
||||
}, DELETION_CONFIRMATION_TIMEOUT_MS);
|
||||
}
|
||||
};
|
||||
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
|
||||
if (event.query === originalQuery) trackRequest();
|
||||
});
|
||||
try {
|
||||
// Bound stalled requests without expiring a check paused offline.
|
||||
const request = queryClient.fetchQuery({
|
||||
queryKey: queryKeys.profiles,
|
||||
queryFn: listProfiles,
|
||||
staleTime: 0,
|
||||
});
|
||||
// Joining an existing refresh emits no new fetching transition.
|
||||
trackRequest();
|
||||
const profiles = await request;
|
||||
if (!profiles.some((profile) => profile.id === id)) forgetProfile(id);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
clearTimer();
|
||||
}
|
||||
} catch {
|
||||
// The original error is already shown; absence is still unconfirmed.
|
||||
}
|
||||
});
|
||||
deletionConfirmations.set(queryClient, confirmation);
|
||||
void confirmation.then(() => {
|
||||
if (deletionConfirmations.get(queryClient) === confirmation) {
|
||||
deletionConfirmations.delete(queryClient);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { listProfiles } from './profiles';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('aborts a stalled profiles transport when its managed query is cancelled', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
let signal: AbortSignal | undefined;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((_url: string, init?: RequestInit) => {
|
||||
signal = init?.signal ?? undefined;
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('Cancelled', 'AbortError')),
|
||||
);
|
||||
});
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const pending = client
|
||||
.fetchQuery({ queryKey: ['profiles'], queryFn: listProfiles })
|
||||
.catch((error: unknown) => error);
|
||||
expect(signal).toBeDefined();
|
||||
await client.cancelQueries({ queryKey: ['profiles'] });
|
||||
await pending;
|
||||
expect(signal?.aborted).toBe(true);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('[]', { status: 200 })),
|
||||
);
|
||||
await expect(
|
||||
client.fetchQuery({ queryKey: ['profiles'], queryFn: listProfiles }),
|
||||
).resolves.toEqual([]);
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
});
|
||||
@@ -13,8 +13,8 @@ export interface CreateCloneProfileInput {
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export async function listProfiles(): Promise<Profile[]> {
|
||||
return apiJson<Profile[]>('/profiles');
|
||||
export async function listProfiles({ signal }: { signal?: AbortSignal } = {}): Promise<Profile[]> {
|
||||
return apiJson<Profile[]>('/profiles', { signal });
|
||||
}
|
||||
|
||||
export async function updateProfileImage(id: string, image: File): Promise<Profile> {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def profile(tmp_path, monkeypatch):
|
||||
from core import db
|
||||
from api.routers import profiles
|
||||
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "profiles.db"))
|
||||
monkeypatch.setattr(profiles, "VOICES_DIR", str(tmp_path))
|
||||
db.init_db()
|
||||
names = ["ref.wav", "locked.wav", "consent.wav", "voice.portrait.jpg"]
|
||||
for name in names:
|
||||
(tmp_path / name).write_bytes(name.encode())
|
||||
with db.db_conn() as conn:
|
||||
conn.execute("INSERT INTO voice_profiles(id,name,ref_audio_path,locked_audio_path,consent_audio_path) VALUES('voice','Voice',?,?,?)", names[:3])
|
||||
conn.execute("INSERT INTO generation_history(id,profile_id) VALUES('history','voice')")
|
||||
return tmp_path, names
|
||||
|
||||
|
||||
@pytest.mark.parametrize("table,operation", [("voice_profiles", "DELETE"), ("generation_history", "UPDATE")])
|
||||
def test_rejected_delete_keeps_assets_and_history(profile, table, operation):
|
||||
from core import db
|
||||
from api.routers import profiles
|
||||
root, names = profile
|
||||
with db.db_conn() as conn:
|
||||
conn.execute(f"CREATE TRIGGER reject_delete BEFORE {operation} ON {table} BEGIN SELECT RAISE(ABORT, 'test write failure'); END")
|
||||
with pytest.raises(sqlite3.IntegrityError, match="test write failure"):
|
||||
profiles.delete_profile("voice")
|
||||
assert [(root / name).read_bytes() for name in names] == [name.encode() for name in names]
|
||||
with db.db_conn() as conn:
|
||||
assert conn.execute("SELECT profile_id FROM generation_history").fetchone()[0] == "voice"
|
||||
assert conn.execute("SELECT count(*) FROM voice_profiles").fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_committed_delete_cleans_all_assets(profile):
|
||||
from core import db
|
||||
from api.routers import profiles
|
||||
root, names = profile
|
||||
assert profiles.delete_profile("voice") == {"deleted": "voice"}
|
||||
assert all(not (root / name).exists() for name in names)
|
||||
with db.db_conn() as conn:
|
||||
assert conn.execute("SELECT profile_id FROM generation_history").fetchone()[0] is None
|
||||
assert conn.execute("SELECT count(*) FROM voice_profiles").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_cleanup_error_after_commit_does_not_stop_other_assets(profile, monkeypatch, caplog):
|
||||
from core import db
|
||||
from api.routers import profiles
|
||||
root, names = profile
|
||||
real_remove = profiles.os.remove
|
||||
def remove(path):
|
||||
if str(path) == str(root / "ref.wav"):
|
||||
raise PermissionError("busy")
|
||||
return real_remove(path)
|
||||
monkeypatch.setattr(profiles.os, "remove", remove)
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
app = FastAPI()
|
||||
app.include_router(profiles.router)
|
||||
response = TestClient(app).delete("/profiles/voice")
|
||||
assert response.status_code == 500
|
||||
detail = response.json()["detail"]
|
||||
assert "profile record was deleted" in detail
|
||||
assert "cleanup is incomplete" in detail and "ref.wav" in detail
|
||||
assert str(root) not in detail
|
||||
assert str(root / "ref.wav") in caplog.text
|
||||
assert (root / "ref.wav").exists()
|
||||
assert all(not (root / name).exists() for name in names[1:])
|
||||
with db.db_conn() as conn:
|
||||
assert conn.execute("SELECT count(*) FROM voice_profiles").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_commit_failure_keeps_assets_and_history(profile):
|
||||
from core import db
|
||||
from api.routers import profiles
|
||||
root, names = profile
|
||||
with db.db_conn() as conn:
|
||||
conn.execute("CREATE TABLE dependent_voice (profile_id TEXT REFERENCES voice_profiles(id) DEFERRABLE INITIALLY DEFERRED)")
|
||||
conn.execute("INSERT INTO dependent_voice VALUES ('voice')")
|
||||
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
|
||||
profiles.delete_profile("voice")
|
||||
assert [(root / name).read_bytes() for name in names] == [name.encode() for name in names]
|
||||
with db.db_conn() as conn:
|
||||
assert conn.execute("SELECT profile_id FROM generation_history").fetchone()[0] == "voice"
|
||||
assert conn.execute("SELECT count(*) FROM voice_profiles").fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_busy_nested_portrait_reports_confined_relative_location(profile, monkeypatch, caplog):
|
||||
from core import db
|
||||
from api.routers import profiles
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
root, names = profile
|
||||
nested = root / "portraits" / "voice.portrait.jpg"
|
||||
nested.parent.mkdir()
|
||||
(root / names[-1]).rename(nested)
|
||||
try:
|
||||
(root / names[-1]).symlink_to(nested)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
real_remove = profiles.os.remove
|
||||
def remove(path):
|
||||
if str(path) == str(nested):
|
||||
raise PermissionError("busy nested portrait")
|
||||
return real_remove(path)
|
||||
monkeypatch.setattr(profiles.os, "remove", remove)
|
||||
app = FastAPI()
|
||||
app.include_router(profiles.router)
|
||||
response = TestClient(app).delete("/profiles/voice")
|
||||
assert response.status_code == 500
|
||||
detail = response.json()["detail"]
|
||||
assert "profile record was deleted" in detail and "cleanup is incomplete" in detail
|
||||
assert str(nested.relative_to(root)) in detail
|
||||
assert str(root) not in detail
|
||||
assert str(nested) in caplog.text
|
||||
assert nested.read_bytes() == names[-1].encode()
|
||||
assert all(not (root / name).exists() for name in names[:-1])
|
||||
with db.db_conn() as conn:
|
||||
assert conn.execute("SELECT count(*) FROM voice_profiles").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT profile_id FROM generation_history").fetchone()[0] is None
|
||||
|
||||
|
||||
def test_cleanup_log_redacts_home_prefix_and_keeps_asset_suffix(profile, monkeypatch, caplog):
|
||||
from api.routers import profiles
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
root, names = profile
|
||||
voices = root / "Users" / "cleanup-test" / "voices"
|
||||
voices.mkdir(parents=True)
|
||||
for name in names:
|
||||
(root / name).rename(voices / name)
|
||||
monkeypatch.setattr(profiles, "VOICES_DIR", str(voices))
|
||||
# Treat this real temporary directory as the process home on every OS.
|
||||
# A nested Users component alone is not a Windows home-directory shape.
|
||||
real_expanduser = profiles.os.path.expanduser
|
||||
monkeypatch.setattr(profiles.os.path, "expanduser", lambda path: str(voices.parent) if path == "~" else real_expanduser(path))
|
||||
real_remove = profiles.os.remove
|
||||
def remove(path):
|
||||
if str(path) == str(voices / "ref.wav"):
|
||||
raise PermissionError("busy")
|
||||
return real_remove(path)
|
||||
monkeypatch.setattr(profiles.os, "remove", remove)
|
||||
app = FastAPI()
|
||||
app.include_router(profiles.router)
|
||||
assert TestClient(app).delete("/profiles/voice").status_code == 500
|
||||
logged = caplog.text.replace("\\", "/")
|
||||
assert "Users/cleanup-test" not in logged
|
||||
assert "voices/ref.wav" in logged
|
||||
Reference in New Issue
Block a user