mirror of
https://github.com/debpalash/VoiceStudio.git
synced 2026-10-02 01:26:35 +08:00
fix(profiles): keep deletion recovery queries coordinated
Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user