Merge PR #2488: commit profile deletion before removing assets

This commit is contained in:
Palash Debnath
2026-10-01 18:41:48 +05:30
8 changed files with 924 additions and 21 deletions
+3
View File
@@ -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!
+30 -4
View File
@@ -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}
+8
View File
@@ -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> {
+150
View File
@@ -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