fix(profiles): keep deletion recovery queries coordinated

Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com>
This commit is contained in:
Rudy Celekli
2026-10-01 01:27:42 -04:00
parent f30fe05b4c
commit dd394a735a
3 changed files with 76 additions and 6 deletions
@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, renderHook } from '@testing-library/react';
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';
@@ -20,7 +20,7 @@ vi.mock('@/lib/api/profiles', () => ({
replaceProfileAudio: mock.replace,
}));
vi.mock('./use-backend-status', () => ({ useBackendStatus: () => ({ stage: 'ready' }) }));
import { useDeleteProfile, 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 } }));
@@ -155,3 +155,64 @@ it('clears selected voices after an ordinary successful deletion', async () => {
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']);
});
@@ -111,8 +111,12 @@ export function useDeleteProfile(): UseMutationResult<void, Error, string> {
// 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.
const profiles = await listProfiles();
queryClient.setQueryData(queryKeys.profiles, profiles);
await queryClient.cancelQueries({ queryKey: queryKeys.profiles });
const profiles = await queryClient.fetchQuery({
queryKey: queryKeys.profiles,
queryFn: listProfiles,
staleTime: 0,
});
if (!profiles.some((profile) => profile.id === id)) forgetProfile(id);
} catch {
// The original error is already shown; absence is still unconfirmed.
+7 -2
View File
@@ -132,6 +132,10 @@ def test_cleanup_log_redacts_home_prefix_and_keeps_asset_suffix(profile, monkeyp
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"):
@@ -141,5 +145,6 @@ def test_cleanup_log_redacts_home_prefix_and_keeps_asset_suffix(profile, monkeyp
app = FastAPI()
app.include_router(profiles.router)
assert TestClient(app).delete("/profiles/voice").status_code == 500
assert "Users/cleanup-test" not in caplog.text
assert "voices/ref.wav" in caplog.text
logged = caplog.text.replace("\\", "/")
assert "Users/cleanup-test" not in logged
assert "voices/ref.wav" in logged