Files
TREK/client/tests/integration/hooks/useDayNotes.test.ts
2026-04-07 12:31:09 +02:00

448 lines
14 KiB
TypeScript

import React from 'react';
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { useDayNotes } from '../../../src/hooks/useDayNotes';
import { useTripStore } from '../../../src/store/tripStore';
import { TranslationProvider } from '../../../src/i18n/TranslationContext';
import { server } from '../../helpers/msw/server';
import { buildDayNote } from '../../helpers/factories';
import { resetAllStores } from '../../helpers/store';
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(TranslationProvider, null, children);
const TRIP_ID = 1;
const DAY_ID = 10;
describe('useDayNotes', () => {
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
});
it('FE-HOOK-DAYNOTES-001: initial noteUi state is empty', () => {
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
expect(result.current.noteUi).toEqual({});
});
it('FE-HOOK-DAYNOTES-002: initial dayNotes comes from tripStore', () => {
const note = buildDayNote({ day_id: DAY_ID });
useTripStore.setState({ dayNotes: { [String(DAY_ID)]: [note] } });
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
expect(result.current.dayNotes[String(DAY_ID)]).toEqual([note]);
});
it('FE-HOOK-DAYNOTES-003: openAddNote sets mode=add and default sort order', () => {
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
act(() => {
result.current.openAddNote(DAY_ID, () => []);
});
expect(result.current.noteUi[DAY_ID]).toMatchObject({
mode: 'add',
text: '',
sortOrder: 0, // maxKey(-1) + 1 = 0
});
});
it('FE-HOOK-DAYNOTES-004: openAddNote calculates sortOrder as max(sortKey) + 1 from merged items', () => {
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
const getMergedItems = () => [
{ type: 'note' as const, sortKey: 5, data: buildDayNote() },
{ type: 'note' as const, sortKey: 10, data: buildDayNote() },
];
act(() => {
result.current.openAddNote(DAY_ID, getMergedItems);
});
expect(result.current.noteUi[DAY_ID]).toMatchObject({
mode: 'add',
sortOrder: 11, // max(5,10) + 1
});
});
it('FE-HOOK-DAYNOTES-005: openEditNote sets mode=edit with note data', () => {
const note = buildDayNote({ id: 99, text: 'Hello', time: '10:00', icon: 'Star' });
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
act(() => {
result.current.openEditNote(DAY_ID, note);
});
expect(result.current.noteUi[DAY_ID]).toMatchObject({
mode: 'edit',
noteId: 99,
text: 'Hello',
time: '10:00',
icon: 'Star',
});
});
it('FE-HOOK-DAYNOTES-006: cancelNote removes the UI entry for that day', () => {
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
act(() => {
result.current.openAddNote(DAY_ID, () => []);
});
expect(result.current.noteUi[DAY_ID]).toBeDefined();
act(() => {
result.current.cancelNote(DAY_ID);
});
expect(result.current.noteUi[DAY_ID]).toBeUndefined();
});
it('FE-HOOK-DAYNOTES-007: saveNote with empty text is a no-op', async () => {
const spy = vi.fn();
server.use(
http.post('/api/trips/:id/days/:dayId/notes', () => {
spy();
return HttpResponse.json({ note: buildDayNote() });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
act(() => {
result.current.setNoteUi({ [DAY_ID]: { mode: 'add', text: '', time: '', icon: 'FileText', sortOrder: 0 } });
});
await act(async () => {
await result.current.saveNote(DAY_ID);
});
expect(spy).not.toHaveBeenCalled();
// noteUi remains set (no cancelNote was called)
expect(result.current.noteUi[DAY_ID]).toBeDefined();
});
it('FE-HOOK-DAYNOTES-008: saveNote in add mode calls addDayNote and clears UI', async () => {
const createdNote = buildDayNote({ day_id: DAY_ID, text: 'New note' });
server.use(
http.post('/api/trips/:id/days/:dayId/notes', async () => {
return HttpResponse.json({ note: createdNote });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
act(() => {
result.current.setNoteUi({
[DAY_ID]: { mode: 'add', text: 'New note', time: '', icon: 'FileText', sortOrder: 0 },
});
});
await act(async () => {
await result.current.saveNote(DAY_ID);
});
// UI should be cleared after successful save
expect(result.current.noteUi[DAY_ID]).toBeUndefined();
});
it('FE-HOOK-DAYNOTES-009: saveNote in edit mode calls updateDayNote and clears UI', async () => {
const noteId = 55;
const updatedNote = buildDayNote({ id: noteId, day_id: DAY_ID, text: 'Updated' });
server.use(
http.put('/api/trips/:id/days/:dayId/notes/:noteId', async () => {
return HttpResponse.json({ note: updatedNote });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
act(() => {
result.current.setNoteUi({
[DAY_ID]: { mode: 'edit', noteId, text: 'Updated', time: '', icon: 'FileText' },
});
});
await act(async () => {
await result.current.saveNote(DAY_ID);
});
expect(result.current.noteUi[DAY_ID]).toBeUndefined();
});
it('FE-HOOK-DAYNOTES-010: deleteNote calls deleteDayNote on the store', async () => {
const note = buildDayNote({ id: 77, day_id: DAY_ID });
useTripStore.setState({ dayNotes: { [String(DAY_ID)]: [note] } });
server.use(
http.delete('/api/trips/:id/days/:dayId/notes/:noteId', () => {
return HttpResponse.json({ success: true });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
await act(async () => {
await result.current.deleteNote(DAY_ID, 77);
});
// Note should be removed from the store
const dayNotes = useTripStore.getState().dayNotes[String(DAY_ID)] || [];
expect(dayNotes.find((n) => n.id === 77)).toBeUndefined();
});
it('FE-HOOK-DAYNOTES-011: saveNote on API error shows toast', async () => {
const toastSpy = vi.fn();
window.__addToast = toastSpy;
server.use(
http.post('/api/trips/:id/days/:dayId/notes', () => {
return HttpResponse.json({ error: 'Server error' }, { status: 500 });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
act(() => {
result.current.setNoteUi({
[DAY_ID]: { mode: 'add', text: 'Test note', time: '', icon: 'FileText', sortOrder: 0 },
});
});
await act(async () => {
await result.current.saveNote(DAY_ID);
});
expect(toastSpy).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
delete window.__addToast;
});
it('FE-HOOK-DAYNOTES-012: deleteNote on API error shows toast', async () => {
const toastSpy = vi.fn();
window.__addToast = toastSpy;
const note = buildDayNote({ id: 88, day_id: DAY_ID });
useTripStore.setState({ dayNotes: { [String(DAY_ID)]: [note] } });
server.use(
http.delete('/api/trips/:id/days/:dayId/notes/:noteId', () => {
return HttpResponse.json({ error: 'Server error' }, { status: 500 });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
await act(async () => {
await result.current.deleteNote(DAY_ID, 88);
});
expect(toastSpy).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
delete window.__addToast;
});
it('FE-HOOK-DAYNOTES-013: moveNote up calculates midpoint sort order', async () => {
let capturedBody: Record<string, unknown> = {};
server.use(
http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ note: buildDayNote() });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
const noteA = buildDayNote({ id: 1 });
const noteB = buildDayNote({ id: 2 });
const noteC = buildDayNote({ id: 3 });
// merged items with sortKeys 0, 2, 4
const getMergedItems = () => [
{ type: 'note' as const, sortKey: 0, data: noteA },
{ type: 'note' as const, sortKey: 2, data: noteB },
{ type: 'note' as const, sortKey: 4, data: noteC },
];
// Move noteC (idx=2) up → new order should be between idx=0 and idx=1 → (0+2)/2 = 1
await act(async () => {
await result.current.moveNote(DAY_ID, noteC.id, 'up', getMergedItems);
});
expect(capturedBody.sort_order).toBe(1); // (sortKey[0] + sortKey[1]) / 2 = (0+2)/2
});
it('FE-HOOK-DAYNOTES-014: moveNote down calculates midpoint sort order', async () => {
let capturedBody: Record<string, unknown> = {};
server.use(
http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ note: buildDayNote() });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
const noteA = buildDayNote({ id: 1 });
const noteB = buildDayNote({ id: 2 });
const noteC = buildDayNote({ id: 3 });
const getMergedItems = () => [
{ type: 'note' as const, sortKey: 0, data: noteA },
{ type: 'note' as const, sortKey: 2, data: noteB },
{ type: 'note' as const, sortKey: 4, data: noteC },
];
// Move noteA (idx=0) down → new order between idx=1 and idx=2 → (2+4)/2 = 3
await act(async () => {
await result.current.moveNote(DAY_ID, noteA.id, 'down', getMergedItems);
});
expect(capturedBody.sort_order).toBe(3); // (sortKey[1] + sortKey[2]) / 2 = (2+4)/2
});
it('FE-HOOK-DAYNOTES-015: moveNote up at index 0 is a no-op', async () => {
const spy = vi.fn();
server.use(
http.put('/api/trips/:id/days/:dayId/notes/:noteId', () => {
spy();
return HttpResponse.json({ note: buildDayNote() });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
const noteA = buildDayNote({ id: 1 });
const getMergedItems = () => [
{ type: 'note' as const, sortKey: 0, data: noteA },
];
await act(async () => {
await result.current.moveNote(DAY_ID, noteA.id, 'up', getMergedItems);
});
expect(spy).not.toHaveBeenCalled();
});
it('FE-HOOK-DAYNOTES-016: moveNote down at last index is a no-op', async () => {
const spy = vi.fn();
server.use(
http.put('/api/trips/:id/days/:dayId/notes/:noteId', () => {
spy();
return HttpResponse.json({ note: buildDayNote() });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
const noteA = buildDayNote({ id: 1 });
const getMergedItems = () => [
{ type: 'note' as const, sortKey: 0, data: noteA },
];
await act(async () => {
await result.current.moveNote(DAY_ID, noteA.id, 'down', getMergedItems);
});
expect(spy).not.toHaveBeenCalled();
});
it('FE-HOOK-DAYNOTES-017: moveNote down at last item uses sortKey + 1', async () => {
let capturedBody: Record<string, unknown> = {};
server.use(
http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ note: buildDayNote() });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
const noteA = buildDayNote({ id: 1 });
const noteB = buildDayNote({ id: 2 });
const getMergedItems = () => [
{ type: 'note' as const, sortKey: 5, data: noteA },
{ type: 'note' as const, sortKey: 10, data: noteB },
];
// Move noteA (idx=0) down — only 2 items, so idx < length-1 is false after going down
// direction=down, idx=0, length=2, idx < length-2 is false (0 < 0), so newSortOrder = sortKey[1]+1 = 11
await act(async () => {
await result.current.moveNote(DAY_ID, noteA.id, 'down', getMergedItems);
});
expect(capturedBody.sort_order).toBe(11); // sortKey[idx+1] + 1 = 10 + 1
});
it('FE-HOOK-DAYNOTES-018: moveNote on error shows toast', async () => {
const toastSpy = vi.fn();
window.__addToast = toastSpy;
server.use(
http.put('/api/trips/:id/days/:dayId/notes/:noteId', () => {
return HttpResponse.json({ error: 'Server error' }, { status: 500 });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
const noteA = buildDayNote({ id: 1 });
const noteB = buildDayNote({ id: 2 });
const getMergedItems = () => [
{ type: 'note' as const, sortKey: 0, data: noteA },
{ type: 'note' as const, sortKey: 1, data: noteB },
];
await act(async () => {
await result.current.moveNote(DAY_ID, noteA.id, 'down', getMergedItems);
});
expect(toastSpy).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
delete window.__addToast;
});
it('FE-HOOK-DAYNOTES-019: moveNote up with only 1 item before uses sortKey - 1', async () => {
let capturedBody: Record<string, unknown> = {};
server.use(
http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ note: buildDayNote() });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
const noteA = buildDayNote({ id: 1 });
const noteB = buildDayNote({ id: 2 });
const getMergedItems = () => [
{ type: 'note' as const, sortKey: 5, data: noteA },
{ type: 'note' as const, sortKey: 10, data: noteB },
];
// Move noteB (idx=1) up — idx >= 2 is false, so newSortOrder = sortKey[idx-1] - 1 = 5-1 = 4
await act(async () => {
await result.current.moveNote(DAY_ID, noteB.id, 'up', getMergedItems);
});
expect(capturedBody.sort_order).toBe(4); // sortKey[0] - 1 = 5 - 1
});
it('FE-HOOK-DAYNOTES-020: openAddNote calls expandDay if provided', () => {
const expandDay = vi.fn();
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
act(() => {
result.current.openAddNote(DAY_ID, () => [], expandDay);
});
expect(expandDay).toHaveBeenCalledWith(DAY_ID);
});
});
// Type augment for window.__addToast
declare global {
interface Window {
__addToast?: (message: string, type: string, duration?: number) => void;
}
}