Files
TREK/client/src/components/Collab/useCollabNotesData.test.ts
T
Maurice dd7cfca0b2 refactor(collab): share the notes list and attachment upload between desktop and phone
useCollabNotesData holds the note list with its first load and the live
note events, and the attachment upload both shells run after saving a
note. Writing notes, their colours and categories stays in each shell,
since the desktop panel keeps empty categories and colour batches the
phone tab does not have. A failed load still empties the desktop list only.
2026-10-09 15:19:43 +02:00

168 lines
6.5 KiB
TypeScript

// FE-COLLAB-NOTESDATA-001 to FE-COLLAB-NOTESDATA-008: the note list behind the desktop
// Collab panel (empties on a failed load) and the phone's notes tab (keeps the list), with
// the live note events and the attachment upload both run after saving a note.
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { collabApi } from '../../api/client';
import { addListener, removeListener } from '../../api/websocket';
import type { CollabNoteData } from './collabModel';
import { useCollabNotesData, type CollabNotesDataOptions } from './useCollabNotesData';
const t = (key: string) => key;
function note(id: number, over: Partial<CollabNoteData> = {}): CollabNoteData {
return {
id,
trip_id: 1,
user_id: 2,
title: `Note ${id}`,
content: null,
category: null,
color: null,
website: null,
pinned: false,
username: 'alice',
avatar: null,
avatar_url: null,
created_at: '2026-05-01T10:00:00Z',
attachments: [],
...over,
};
}
function setup(over: Partial<CollabNotesDataOptions> = {}) {
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
const hook = renderHook(
(props: Partial<CollabNotesDataOptions>) => useCollabNotesData({ tripId: 1, t, toast, ...over, ...props }),
{ initialProps: {} }
);
return { ...hook, toast };
}
async function loaded(over: Partial<CollabNotesDataOptions> = {}) {
const view = setup(over);
await waitFor(() => expect(view.result.current.loading).toBe(false));
return view;
}
function wsHandler(): (event: Record<string, unknown>) => void {
const calls = vi.mocked(addListener).mock.calls;
return calls[calls.length - 1][0] as (event: Record<string, unknown>) => void;
}
beforeEach(() => {
vi.mocked(addListener).mockClear();
vi.mocked(removeListener).mockClear();
vi.spyOn(collabApi, 'getNotes').mockResolvedValue({ notes: [note(1), note(2)] });
vi.spyOn(collabApi, 'uploadNoteFile').mockResolvedValue({});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('useCollabNotesData', () => {
it('FE-COLLAB-NOTESDATA-001: loads the notes, from an object or a bare array', async () => {
const { result } = setup();
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(collabApi.getNotes).toHaveBeenCalledWith(1);
expect(result.current.notes.map((n) => n.id)).toEqual([1, 2]);
vi.mocked(collabApi.getNotes).mockResolvedValue([note(3)]);
const bare = await loaded();
expect(bare.result.current.notes.map((n) => n.id)).toEqual([3]);
vi.mocked(collabApi.getNotes).mockResolvedValue({});
const empty = await loaded();
expect(empty.result.current.notes).toEqual([]);
});
it('FE-COLLAB-NOTESDATA-002: without a trip nothing loads or listens', () => {
const { result } = setup({ tripId: 0 });
expect(collabApi.getNotes).not.toHaveBeenCalled();
expect(addListener).not.toHaveBeenCalled();
expect(result.current.loading).toBe(true);
});
it('FE-COLLAB-NOTESDATA-003: a failed reload empties the desktop list and keeps the phone list', async () => {
const desktop = await loaded({ resetOnLoadError: true });
const phone = await loaded();
vi.mocked(collabApi.getNotes).mockRejectedValue(new Error('boom'));
desktop.rerender({ tripId: 2 });
phone.rerender({ tripId: 2 });
await waitFor(() => expect(desktop.result.current.loading).toBe(false));
await waitFor(() => expect(phone.result.current.loading).toBe(false));
expect(desktop.result.current.notes).toEqual([]);
expect(phone.result.current.notes.map((n) => n.id)).toEqual([1, 2]);
});
it('FE-COLLAB-NOTESDATA-004: live events create, update and delete, only for this trip', async () => {
const { result } = await loaded();
const handler = wsHandler();
act(() => {
handler({ type: 'collab:note:created', tripId: 2, note: note(7) });
handler({ type: 'collab:note:created', tripId: '1', note: note(7) });
handler({ type: 'collab:note:created', tripId: 1, note: note(7) });
handler({ type: 'collab:note:updated', tripId: 1, note: { id: 1, title: 'renamed' } });
handler({ type: 'collab:note:updated', tripId: 1 });
});
expect(result.current.notes.map((n) => n.id)).toEqual([7, 1, 2]);
// An update folds into the note it names.
expect(result.current.notes[1]).toMatchObject({ title: 'renamed', username: 'alice' });
act(() => {
handler({ type: 'collab:note:deleted', tripId: 1, noteId: 7 });
handler({ type: 'collab:note:deleted', tripId: 1, id: 1 });
handler({ type: 'collab:note:deleted', tripId: 1 });
});
expect(result.current.notes.map((n) => n.id)).toEqual([2]);
});
it('FE-COLLAB-NOTESDATA-005: unmounting removes the live listener', async () => {
const { unmount } = await loaded();
const handler = wsHandler();
unmount();
expect(removeListener).toHaveBeenCalledWith(handler);
});
it('FE-COLLAB-NOTESDATA-006: attachments upload one by one as a file field', async () => {
const { result, toast } = await loaded();
const a = new File(['a'], 'a.pdf');
const b = new File(['b'], 'b.png');
await act(async () => {
await result.current.uploadNoteFiles(5, [a, b]);
});
const calls = vi.mocked(collabApi.uploadNoteFile).mock.calls;
expect(calls.map((c) => [c[0], c[1]])).toEqual([
[1, 5],
[1, 5],
]);
expect(((calls[0][2] as FormData).get('file') as File).name).toBe(a.name);
expect(((calls[1][2] as FormData).get('file') as File).name).toBe(b.name);
expect(toast.error).not.toHaveBeenCalled();
});
it('FE-COLLAB-NOTESDATA-007: a refused file is reported and toasted, the rest still go', async () => {
const err = new Error('too big');
vi.mocked(collabApi.uploadNoteFile).mockRejectedValueOnce(err);
const onFileError = vi.fn();
const { result, toast } = await loaded();
await act(async () => {
await result.current.uploadNoteFiles(5, [new File(['a'], 'a.pdf'), new File(['b'], 'b.pdf')], onFileError);
});
expect(collabApi.uploadNoteFile).toHaveBeenCalledTimes(2);
expect(onFileError).toHaveBeenCalledWith(err);
expect(toast.error).toHaveBeenCalledTimes(1);
expect(toast.error).toHaveBeenCalledWith('common.error');
});
it('FE-COLLAB-NOTESDATA-008: setNotes lets a shell write the list it reloaded', async () => {
const { result } = await loaded();
act(() => result.current.setNotes([note(9)]));
expect(result.current.notes.map((n) => n.id)).toEqual([9]);
});
});