mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
test(front): add test suite frontend (WIP)
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { usePlaceSelection } from '../../../src/hooks/usePlaceSelection';
|
||||
|
||||
// FE-HOOK-SEL-001 onwards
|
||||
|
||||
describe('usePlaceSelection', () => {
|
||||
it('FE-HOOK-SEL-001: initially both IDs are null', () => {
|
||||
const { result } = renderHook(() => usePlaceSelection());
|
||||
expect(result.current.selectedPlaceId).toBeNull();
|
||||
expect(result.current.selectedAssignmentId).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-HOOK-SEL-002: setSelectedPlaceId sets selectedPlaceId', () => {
|
||||
const { result } = renderHook(() => usePlaceSelection());
|
||||
act(() => { result.current.setSelectedPlaceId(42); });
|
||||
expect(result.current.selectedPlaceId).toBe(42);
|
||||
});
|
||||
|
||||
it('FE-HOOK-SEL-003: setSelectedPlaceId clears selectedAssignmentId', () => {
|
||||
const { result } = renderHook(() => usePlaceSelection());
|
||||
// First set an assignment via selectAssignment
|
||||
act(() => { result.current.selectAssignment(99, 10); });
|
||||
expect(result.current.selectedAssignmentId).toBe(99);
|
||||
|
||||
// Now change the place — assignment must be cleared
|
||||
act(() => { result.current.setSelectedPlaceId(20); });
|
||||
expect(result.current.selectedPlaceId).toBe(20);
|
||||
expect(result.current.selectedAssignmentId).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-HOOK-SEL-004: selectAssignment sets both selectedAssignmentId and selectedPlaceId', () => {
|
||||
const { result } = renderHook(() => usePlaceSelection());
|
||||
act(() => { result.current.selectAssignment(7, 3); });
|
||||
expect(result.current.selectedAssignmentId).toBe(7);
|
||||
expect(result.current.selectedPlaceId).toBe(3);
|
||||
});
|
||||
|
||||
it('FE-HOOK-SEL-005: setSelectedPlaceId(null) resets selectedPlaceId to null and clears assignment', () => {
|
||||
const { result } = renderHook(() => usePlaceSelection());
|
||||
act(() => { result.current.selectAssignment(5, 1); });
|
||||
act(() => { result.current.setSelectedPlaceId(null); });
|
||||
expect(result.current.selectedPlaceId).toBeNull();
|
||||
expect(result.current.selectedAssignmentId).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-HOOK-SEL-006: selectAssignment(null, null) clears both IDs', () => {
|
||||
const { result } = renderHook(() => usePlaceSelection());
|
||||
act(() => { result.current.selectAssignment(5, 1); });
|
||||
act(() => { result.current.selectAssignment(null, null); });
|
||||
expect(result.current.selectedAssignmentId).toBeNull();
|
||||
expect(result.current.selectedPlaceId).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-HOOK-SEL-007: selecting a different place after an assignment clears the assignment', () => {
|
||||
const { result } = renderHook(() => usePlaceSelection());
|
||||
act(() => { result.current.selectAssignment(11, 5); });
|
||||
// Switch to a different place without going through selectAssignment
|
||||
act(() => { result.current.setSelectedPlaceId(99); });
|
||||
expect(result.current.selectedPlaceId).toBe(99);
|
||||
expect(result.current.selectedAssignmentId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { usePlannerHistory } from '../../../src/hooks/usePlannerHistory';
|
||||
|
||||
// FE-HOOK-HIST-001 onwards
|
||||
|
||||
describe('usePlannerHistory', () => {
|
||||
it('FE-HOOK-HIST-001: starts with canUndo=false and lastActionLabel=null', () => {
|
||||
const { result } = renderHook(() => usePlannerHistory());
|
||||
expect(result.current.canUndo).toBe(false);
|
||||
expect(result.current.lastActionLabel).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-HOOK-HIST-002: pushing an entry sets canUndo=true and lastActionLabel', () => {
|
||||
const { result } = renderHook(() => usePlannerHistory());
|
||||
act(() => {
|
||||
result.current.pushUndo('Delete place', vi.fn());
|
||||
});
|
||||
expect(result.current.canUndo).toBe(true);
|
||||
expect(result.current.lastActionLabel).toBe('Delete place');
|
||||
});
|
||||
|
||||
it('FE-HOOK-HIST-003: calling undo fires the undo function and sets canUndo=false', async () => {
|
||||
const { result } = renderHook(() => usePlannerHistory());
|
||||
const undoFn = vi.fn();
|
||||
act(() => {
|
||||
result.current.pushUndo('Add place', undoFn);
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.undo();
|
||||
});
|
||||
expect(undoFn).toHaveBeenCalledOnce();
|
||||
expect(result.current.canUndo).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-HIST-004: multiple entries stack in LIFO order', () => {
|
||||
const { result } = renderHook(() => usePlannerHistory());
|
||||
act(() => {
|
||||
result.current.pushUndo('First', vi.fn());
|
||||
result.current.pushUndo('Second', vi.fn());
|
||||
result.current.pushUndo('Third', vi.fn());
|
||||
});
|
||||
expect(result.current.lastActionLabel).toBe('Third');
|
||||
});
|
||||
|
||||
it('FE-HOOK-HIST-005: undo consumes entries in LIFO order', async () => {
|
||||
const { result } = renderHook(() => usePlannerHistory());
|
||||
const fn1 = vi.fn();
|
||||
const fn2 = vi.fn();
|
||||
act(() => {
|
||||
result.current.pushUndo('First', fn1);
|
||||
result.current.pushUndo('Second', fn2);
|
||||
});
|
||||
await act(async () => { await result.current.undo(); });
|
||||
expect(fn2).toHaveBeenCalledOnce();
|
||||
expect(fn1).not.toHaveBeenCalled();
|
||||
expect(result.current.lastActionLabel).toBe('First');
|
||||
|
||||
await act(async () => { await result.current.undo(); });
|
||||
expect(fn1).toHaveBeenCalledOnce();
|
||||
expect(result.current.canUndo).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-HIST-006: caps history at 30 entries', () => {
|
||||
const { result } = renderHook(() => usePlannerHistory());
|
||||
act(() => {
|
||||
for (let i = 0; i < 31; i++) {
|
||||
result.current.pushUndo(`Action ${i}`, vi.fn());
|
||||
}
|
||||
});
|
||||
// After 31 pushes with cap=30, the oldest entry (Action 0) should be dropped.
|
||||
// canUndo must be true and the stack should not exceed 30.
|
||||
expect(result.current.canUndo).toBe(true);
|
||||
expect(result.current.lastActionLabel).toBe('Action 30');
|
||||
});
|
||||
|
||||
it('FE-HOOK-HIST-007: undo on an empty stack does not throw', async () => {
|
||||
const { result } = renderHook(() => usePlannerHistory());
|
||||
await expect(
|
||||
act(async () => { await result.current.undo(); })
|
||||
).resolves.not.toThrow();
|
||||
expect(result.current.canUndo).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-HIST-008: undo still sets canUndo=false after consuming the last entry', async () => {
|
||||
const { result } = renderHook(() => usePlannerHistory());
|
||||
act(() => { result.current.pushUndo('Only', vi.fn()); });
|
||||
await act(async () => { await result.current.undo(); });
|
||||
expect(result.current.canUndo).toBe(false);
|
||||
expect(result.current.lastActionLabel).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildDay, buildAssignment, buildPlace } from '../../helpers/factories';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('remoteEventHandler > assignments', () => {
|
||||
const seedData = () => {
|
||||
useTripStore.setState({
|
||||
days: [buildDay({ id: 10 }), buildDay({ id: 20 })],
|
||||
assignments: {
|
||||
'10': [buildAssignment({ id: 100, day_id: 10 })],
|
||||
'20': [],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it('FE-WSEVT-ASSIGN-001: assignment:created adds assignment to correct day', () => {
|
||||
seedData();
|
||||
const newAssignment = buildAssignment({ id: 200, day_id: 20 });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: newAssignment });
|
||||
const { assignments } = useTripStore.getState();
|
||||
expect(assignments['20']).toHaveLength(1);
|
||||
expect(assignments['20'][0].id).toBe(200);
|
||||
expect(assignments['10']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-ASSIGN-002: assignment:created is idempotent — no duplicate if same ID', () => {
|
||||
seedData();
|
||||
const duplicate = buildAssignment({ id: 100, day_id: 10 });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: duplicate });
|
||||
const { assignments } = useTripStore.getState();
|
||||
expect(assignments['10']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-ASSIGN-003: assignment:created replaces temp (negative) ID assignment with same place_id', () => {
|
||||
const place = buildPlace({ id: 55 });
|
||||
const tempAssignment = buildAssignment({ id: -1, day_id: 10, place, place_id: place.id });
|
||||
useTripStore.setState({
|
||||
days: [buildDay({ id: 10 })],
|
||||
assignments: { '10': [tempAssignment] },
|
||||
});
|
||||
const realAssignment = buildAssignment({ id: 500, day_id: 10, place, place_id: place.id });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: realAssignment });
|
||||
const { assignments } = useTripStore.getState();
|
||||
expect(assignments['10']).toHaveLength(1);
|
||||
expect(assignments['10'][0].id).toBe(500);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-ASSIGN-004: assignment:updated merges updated data into correct day', () => {
|
||||
seedData();
|
||||
const updated = buildAssignment({ id: 100, day_id: 10, notes: 'Updated notes' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'assignment:updated', assignment: updated });
|
||||
const { assignments } = useTripStore.getState();
|
||||
expect(assignments['10'][0].notes).toBe('Updated notes');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-ASSIGN-005: assignment:deleted removes assignment from day', () => {
|
||||
seedData();
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'assignment:deleted', assignmentId: 100, dayId: 10 });
|
||||
const { assignments } = useTripStore.getState();
|
||||
expect(assignments['10']).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-ASSIGN-006: assignment:moved removes from old day and adds to new day', () => {
|
||||
const movedAssignment = buildAssignment({ id: 100, day_id: 20 });
|
||||
useTripStore.setState({
|
||||
days: [buildDay({ id: 10 }), buildDay({ id: 20 })],
|
||||
assignments: {
|
||||
'10': [movedAssignment],
|
||||
'20': [],
|
||||
},
|
||||
});
|
||||
useTripStore.getState().handleRemoteEvent({
|
||||
type: 'assignment:moved',
|
||||
assignment: movedAssignment,
|
||||
oldDayId: 10,
|
||||
newDayId: 20,
|
||||
});
|
||||
const { assignments } = useTripStore.getState();
|
||||
expect(assignments['10']).toHaveLength(0);
|
||||
expect(assignments['20']).toHaveLength(1);
|
||||
expect(assignments['20'][0].id).toBe(100);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-ASSIGN-007: assignment:reordered updates order_index values', () => {
|
||||
const a1 = buildAssignment({ id: 1, day_id: 10, order_index: 0 });
|
||||
const a2 = buildAssignment({ id: 2, day_id: 10, order_index: 1 });
|
||||
const a3 = buildAssignment({ id: 3, day_id: 10, order_index: 2 });
|
||||
useTripStore.setState({
|
||||
assignments: { '10': [a1, a2, a3] },
|
||||
});
|
||||
useTripStore.getState().handleRemoteEvent({
|
||||
type: 'assignment:reordered',
|
||||
dayId: 10,
|
||||
orderedIds: [3, 1, 2],
|
||||
});
|
||||
const { assignments } = useTripStore.getState();
|
||||
const reordered = assignments['10'];
|
||||
const item3 = reordered.find(a => a.id === 3);
|
||||
const item1 = reordered.find(a => a.id === 1);
|
||||
const item2 = reordered.find(a => a.id === 2);
|
||||
expect(item3?.order_index).toBe(0);
|
||||
expect(item1?.order_index).toBe(1);
|
||||
expect(item2?.order_index).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildBudgetItem } from '../../helpers/factories';
|
||||
import type { BudgetMember } from '../../../src/types';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('remoteEventHandler > budget', () => {
|
||||
const member1: BudgetMember = { user_id: 5, paid: false };
|
||||
const member2: BudgetMember = { user_id: 6, paid: true };
|
||||
|
||||
const seedData = () => {
|
||||
useTripStore.setState({
|
||||
budgetItems: [
|
||||
buildBudgetItem({ id: 1, persons: 1, members: [{ ...member1 }] }),
|
||||
buildBudgetItem({ id: 2, persons: 2, members: [{ ...member2 }] }),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
it('FE-WSEVT-BUDGET-001: budget:created adds item to budgetItems', () => {
|
||||
seedData();
|
||||
const newItem = buildBudgetItem({ id: 99, name: 'Hotel' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'budget:created', item: newItem });
|
||||
const { budgetItems } = useTripStore.getState();
|
||||
expect(budgetItems).toHaveLength(3);
|
||||
expect(budgetItems.find(i => i.id === 99)).toBeDefined();
|
||||
});
|
||||
|
||||
it('FE-WSEVT-BUDGET-002: budget:created is idempotent — no duplicate if same ID', () => {
|
||||
seedData();
|
||||
const duplicate = buildBudgetItem({ id: 1, name: 'Duplicate' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'budget:created', item: duplicate });
|
||||
const { budgetItems } = useTripStore.getState();
|
||||
expect(budgetItems).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-BUDGET-003: budget:updated replaces item in array', () => {
|
||||
seedData();
|
||||
const updated = buildBudgetItem({ id: 1, name: 'Updated Hotel', amount: 500 });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'budget:updated', item: updated });
|
||||
const { budgetItems } = useTripStore.getState();
|
||||
const item = budgetItems.find(i => i.id === 1);
|
||||
expect(item?.name).toBe('Updated Hotel');
|
||||
expect(item?.amount).toBe(500);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-BUDGET-004: budget:deleted removes item by ID', () => {
|
||||
seedData();
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'budget:deleted', itemId: 1 });
|
||||
const { budgetItems } = useTripStore.getState();
|
||||
expect(budgetItems).toHaveLength(1);
|
||||
expect(budgetItems.find(i => i.id === 1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-WSEVT-BUDGET-005: budget:members-updated replaces entire members array and persons count', () => {
|
||||
seedData();
|
||||
const newMembers: BudgetMember[] = [{ user_id: 7, paid: true }, { user_id: 8, paid: false }];
|
||||
useTripStore.getState().handleRemoteEvent({
|
||||
type: 'budget:members-updated',
|
||||
itemId: 1,
|
||||
members: newMembers,
|
||||
persons: 3,
|
||||
});
|
||||
const { budgetItems } = useTripStore.getState();
|
||||
const item = budgetItems.find(i => i.id === 1);
|
||||
expect(item?.members).toEqual(newMembers);
|
||||
expect(item?.persons).toBe(3);
|
||||
// Other item should be unchanged
|
||||
const item2 = budgetItems.find(i => i.id === 2);
|
||||
expect(item2?.members).toEqual([{ ...member2 }]);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-BUDGET-006: budget:member-paid-updated toggles specific member paid status', () => {
|
||||
seedData();
|
||||
useTripStore.getState().handleRemoteEvent({
|
||||
type: 'budget:member-paid-updated',
|
||||
itemId: 1,
|
||||
userId: 5,
|
||||
paid: true,
|
||||
});
|
||||
const { budgetItems } = useTripStore.getState();
|
||||
const item = budgetItems.find(i => i.id === 1);
|
||||
const m = item?.members?.find(m => m.user_id === 5);
|
||||
expect(m?.paid).toBe(true);
|
||||
// Other item members unchanged
|
||||
const item2 = budgetItems.find(i => i.id === 2);
|
||||
expect(item2?.members?.[0].paid).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildDayNote } from '../../helpers/factories';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('remoteEventHandler > dayNotes', () => {
|
||||
const seedData = () => {
|
||||
useTripStore.setState({
|
||||
dayNotes: {
|
||||
'10': [buildDayNote({ id: 1, day_id: 10, text: 'Original' })],
|
||||
'20': [],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it('FE-WSEVT-DAYNOTE-001: dayNote:created adds note to correct day', () => {
|
||||
seedData();
|
||||
const newNote = buildDayNote({ id: 99, day_id: 10, text: 'New note' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'dayNote:created', dayId: 10, note: newNote });
|
||||
const { dayNotes } = useTripStore.getState();
|
||||
expect(dayNotes['10']).toHaveLength(2);
|
||||
expect(dayNotes['10'].find(n => n.id === 99)).toBeDefined();
|
||||
});
|
||||
|
||||
it('FE-WSEVT-DAYNOTE-002: dayNote:created is idempotent — no duplicate if same ID', () => {
|
||||
seedData();
|
||||
const duplicate = buildDayNote({ id: 1, day_id: 10, text: 'Duplicate' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'dayNote:created', dayId: 10, note: duplicate });
|
||||
const { dayNotes } = useTripStore.getState();
|
||||
expect(dayNotes['10']).toHaveLength(1);
|
||||
expect(dayNotes['10'][0].text).toBe('Original');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-DAYNOTE-003: dayNote:updated replaces note in correct day', () => {
|
||||
seedData();
|
||||
const updated = buildDayNote({ id: 1, day_id: 10, text: 'Updated text' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'dayNote:updated', dayId: 10, note: updated });
|
||||
const { dayNotes } = useTripStore.getState();
|
||||
expect(dayNotes['10'][0].text).toBe('Updated text');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-DAYNOTE-004: dayNote:deleted removes note from correct day', () => {
|
||||
seedData();
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'dayNote:deleted', dayId: 10, noteId: 1 });
|
||||
const { dayNotes } = useTripStore.getState();
|
||||
expect(dayNotes['10']).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-DAYNOTE-005: operations on day 10 do not affect day 20', () => {
|
||||
seedData();
|
||||
const newNote = buildDayNote({ id: 50, day_id: 10, text: 'Day 10 note' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'dayNote:created', dayId: 10, note: newNote });
|
||||
const { dayNotes } = useTripStore.getState();
|
||||
expect(dayNotes['20']).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildDay, buildAssignment, buildDayNote } from '../../helpers/factories';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('remoteEventHandler > days', () => {
|
||||
const seedData = () => {
|
||||
useTripStore.setState({
|
||||
days: [buildDay({ id: 10 }), buildDay({ id: 20 })],
|
||||
assignments: {
|
||||
'10': [buildAssignment({ id: 100, day_id: 10 })],
|
||||
'20': [],
|
||||
},
|
||||
dayNotes: {
|
||||
'10': [buildDayNote({ id: 1, day_id: 10 })],
|
||||
'20': [],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it('FE-WSEVT-DAY-001: day:created adds day to days array', () => {
|
||||
seedData();
|
||||
const newDay = buildDay({ id: 30 });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'day:created', day: newDay });
|
||||
const { days } = useTripStore.getState();
|
||||
expect(days).toHaveLength(3);
|
||||
expect(days.find(d => d.id === 30)).toBeDefined();
|
||||
});
|
||||
|
||||
it('FE-WSEVT-DAY-002: day:created is idempotent — no duplicate if same ID', () => {
|
||||
seedData();
|
||||
const duplicate = buildDay({ id: 10 });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'day:created', day: duplicate });
|
||||
const { days } = useTripStore.getState();
|
||||
expect(days).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-DAY-003: day:updated replaces day in days array', () => {
|
||||
seedData();
|
||||
const updated = buildDay({ id: 10, title: 'New Title' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'day:updated', day: updated });
|
||||
const { days } = useTripStore.getState();
|
||||
const day10 = days.find(d => d.id === 10);
|
||||
expect(day10?.title).toBe('New Title');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-DAY-004: day:deleted removes day from days array', () => {
|
||||
seedData();
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'day:deleted', dayId: 10 });
|
||||
const { days } = useTripStore.getState();
|
||||
expect(days).toHaveLength(1);
|
||||
expect(days.find(d => d.id === 10)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-WSEVT-DAY-005: day:deleted removes the assignments key for deleted day', () => {
|
||||
seedData();
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'day:deleted', dayId: 10 });
|
||||
const { assignments } = useTripStore.getState();
|
||||
expect('10' in assignments).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-DAY-006: day:deleted removes the dayNotes key for deleted day', () => {
|
||||
seedData();
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'day:deleted', dayId: 10 });
|
||||
const { dayNotes } = useTripStore.getState();
|
||||
expect('10' in dayNotes).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-DAY-007: day:deleted does not remove other days assignments/dayNotes', () => {
|
||||
seedData();
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'day:deleted', dayId: 10 });
|
||||
const { assignments, dayNotes } = useTripStore.getState();
|
||||
expect('20' in assignments).toBe(true);
|
||||
expect('20' in dayNotes).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildTripFile } from '../../helpers/factories';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('remoteEventHandler > files', () => {
|
||||
const seedData = () => {
|
||||
useTripStore.setState({
|
||||
files: [buildTripFile({ id: 1, original_name: 'document.pdf' })],
|
||||
});
|
||||
};
|
||||
|
||||
it('FE-WSEVT-FILE-001: file:created prepends new file to array', () => {
|
||||
seedData();
|
||||
const newFile = buildTripFile({ id: 99, original_name: 'photo.jpg' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'file:created', file: newFile });
|
||||
const { files } = useTripStore.getState();
|
||||
expect(files).toHaveLength(2);
|
||||
expect(files[0].id).toBe(99); // prepended
|
||||
});
|
||||
|
||||
it('FE-WSEVT-FILE-002: file:created is idempotent — no duplicate if same ID', () => {
|
||||
seedData();
|
||||
const duplicate = buildTripFile({ id: 1, original_name: 'document_dup.pdf' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'file:created', file: duplicate });
|
||||
const { files } = useTripStore.getState();
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0].original_name).toBe('document.pdf');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-FILE-003: file:updated replaces file in array', () => {
|
||||
seedData();
|
||||
const updated = buildTripFile({ id: 1, original_name: 'renamed.pdf' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'file:updated', file: updated });
|
||||
const { files } = useTripStore.getState();
|
||||
expect(files[0].original_name).toBe('renamed.pdf');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-FILE-004: file:deleted removes file by ID', () => {
|
||||
seedData();
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'file:deleted', fileId: 1 });
|
||||
const { files } = useTripStore.getState();
|
||||
expect(files).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-FILE-005: file:created ordering — newest is first', () => {
|
||||
seedData();
|
||||
const f2 = buildTripFile({ id: 2, original_name: 'second.pdf' });
|
||||
const f3 = buildTripFile({ id: 3, original_name: 'third.pdf' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'file:created', file: f2 });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'file:created', file: f3 });
|
||||
const { files } = useTripStore.getState();
|
||||
expect(files[0].id).toBe(3);
|
||||
expect(files[1].id).toBe(2);
|
||||
expect(files[2].id).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildPlace } from '../../helpers/factories';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('remoteEventHandler > memories', () => {
|
||||
it('FE-WSEVT-MEM-001: memories:updated dispatches CustomEvent on window', () => {
|
||||
const received: Event[] = [];
|
||||
const handler = (e: Event) => received.push(e);
|
||||
window.addEventListener('memories:updated', handler);
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'memories:updated', photos: [] });
|
||||
window.removeEventListener('memories:updated', handler);
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-MEM-002: memories:updated event type is correct', () => {
|
||||
const received: Event[] = [];
|
||||
const handler = (e: Event) => received.push(e);
|
||||
window.addEventListener('memories:updated', handler);
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'memories:updated', photos: [] });
|
||||
window.removeEventListener('memories:updated', handler);
|
||||
expect(received[0].type).toBe('memories:updated');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-MEM-003: memories:updated event detail contains the payload', () => {
|
||||
const received: CustomEvent[] = [];
|
||||
const handler = (e: Event) => received.push(e as CustomEvent);
|
||||
window.addEventListener('memories:updated', handler);
|
||||
const payload = { photos: [{ id: 1, url: '/photo.jpg' }] };
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'memories:updated', ...payload });
|
||||
window.removeEventListener('memories:updated', handler);
|
||||
expect(received[0].detail).toMatchObject(payload);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-MEM-004: memories:updated does not modify store state', () => {
|
||||
const places = [buildPlace({ id: 42, name: 'Eiffel Tower' })];
|
||||
useTripStore.setState({ places });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'memories:updated', photos: [] });
|
||||
const { places: afterPlaces } = useTripStore.getState();
|
||||
expect(afterPlaces).toHaveLength(1);
|
||||
expect(afterPlaces[0].id).toBe(42);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-MEM-005: memories:updated fires exactly once per event', () => {
|
||||
const received: Event[] = [];
|
||||
const handler = (e: Event) => received.push(e);
|
||||
window.addEventListener('memories:updated', handler);
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'memories:updated', photos: [] });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'memories:updated', photos: [] });
|
||||
window.removeEventListener('memories:updated', handler);
|
||||
expect(received).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildPackingItem } from '../../helpers/factories';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('remoteEventHandler > packing', () => {
|
||||
const seedData = () => {
|
||||
useTripStore.setState({
|
||||
packingItems: [buildPackingItem({ id: 1, name: 'Sunscreen' })],
|
||||
});
|
||||
};
|
||||
|
||||
it('FE-WSEVT-PACK-001: packing:created adds item to packingItems', () => {
|
||||
seedData();
|
||||
const newItem = buildPackingItem({ id: 99, name: 'Hat' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'packing:created', item: newItem });
|
||||
const { packingItems } = useTripStore.getState();
|
||||
expect(packingItems).toHaveLength(2);
|
||||
expect(packingItems.find(i => i.id === 99)).toBeDefined();
|
||||
});
|
||||
|
||||
it('FE-WSEVT-PACK-002: packing:created is idempotent — no duplicate if same ID', () => {
|
||||
seedData();
|
||||
const duplicate = buildPackingItem({ id: 1, name: 'Sunscreen Duplicate' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'packing:created', item: duplicate });
|
||||
const { packingItems } = useTripStore.getState();
|
||||
expect(packingItems).toHaveLength(1);
|
||||
expect(packingItems[0].name).toBe('Sunscreen');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-PACK-003: packing:updated replaces item in array', () => {
|
||||
seedData();
|
||||
const updated = buildPackingItem({ id: 1, name: 'SPF 50 Sunscreen' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'packing:updated', item: updated });
|
||||
const { packingItems } = useTripStore.getState();
|
||||
expect(packingItems[0].name).toBe('SPF 50 Sunscreen');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-PACK-004: packing:deleted removes item by ID', () => {
|
||||
seedData();
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'packing:deleted', itemId: 1 });
|
||||
const { packingItems } = useTripStore.getState();
|
||||
expect(packingItems).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildPlace, buildAssignment } from '../../helpers/factories';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('remoteEventHandler > places', () => {
|
||||
const seedData = () => {
|
||||
const place = buildPlace({ id: 1, name: 'Original' });
|
||||
const assignment = buildAssignment({ id: 100, place, day_id: 10 });
|
||||
useTripStore.setState({
|
||||
places: [place],
|
||||
assignments: { '10': [assignment] },
|
||||
});
|
||||
};
|
||||
|
||||
it('FE-WSEVT-PLACE-001: place:created prepends new place to places array', () => {
|
||||
seedData();
|
||||
const newPlace = buildPlace({ id: 99, name: 'New Place' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'place:created', place: newPlace });
|
||||
const { places } = useTripStore.getState();
|
||||
expect(places[0].id).toBe(99);
|
||||
expect(places).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-PLACE-002: place:created is idempotent — no duplicate if same ID', () => {
|
||||
seedData();
|
||||
const duplicate = buildPlace({ id: 1, name: 'Duplicate' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'place:created', place: duplicate });
|
||||
const { places } = useTripStore.getState();
|
||||
expect(places).toHaveLength(1);
|
||||
expect(places[0].name).toBe('Original');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-PLACE-003: place:updated updates place in places array', () => {
|
||||
seedData();
|
||||
const updated = buildPlace({ id: 1, name: 'Updated Name' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'place:updated', place: updated });
|
||||
const { places } = useTripStore.getState();
|
||||
expect(places[0].name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-PLACE-004: place:updated cascades into assignments nested place', () => {
|
||||
seedData();
|
||||
const updated = buildPlace({ id: 1, name: 'Cascaded Update' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'place:updated', place: updated });
|
||||
const { assignments } = useTripStore.getState();
|
||||
expect(assignments['10'][0].place?.name).toBe('Cascaded Update');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-PLACE-005: place:deleted removes place from places array', () => {
|
||||
seedData();
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'place:deleted', placeId: 1 });
|
||||
const { places } = useTripStore.getState();
|
||||
expect(places).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-PLACE-006: place:deleted cascades — assignments referencing that place are removed', () => {
|
||||
seedData();
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'place:deleted', placeId: 1 });
|
||||
const { assignments } = useTripStore.getState();
|
||||
expect(assignments['10']).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildReservation } from '../../helpers/factories';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('remoteEventHandler > reservations', () => {
|
||||
const seedData = () => {
|
||||
useTripStore.setState({
|
||||
reservations: [buildReservation({ id: 1, name: 'Hotel Paris' })],
|
||||
});
|
||||
};
|
||||
|
||||
it('FE-WSEVT-RESERV-001: reservation:created prepends new reservation to array', () => {
|
||||
seedData();
|
||||
const newRes = buildReservation({ id: 99, name: 'Flight' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: newRes });
|
||||
const { reservations } = useTripStore.getState();
|
||||
expect(reservations).toHaveLength(2);
|
||||
expect(reservations[0].id).toBe(99); // prepended, so first
|
||||
});
|
||||
|
||||
it('FE-WSEVT-RESERV-002: reservation:created is idempotent — no duplicate if same ID', () => {
|
||||
seedData();
|
||||
const duplicate = buildReservation({ id: 1, name: 'Hotel Paris Dup' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: duplicate });
|
||||
const { reservations } = useTripStore.getState();
|
||||
expect(reservations).toHaveLength(1);
|
||||
expect(reservations[0].name).toBe('Hotel Paris');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-RESERV-003: reservation:updated replaces reservation in array', () => {
|
||||
seedData();
|
||||
const updated = buildReservation({ id: 1, name: 'Hotel Lyon' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'reservation:updated', reservation: updated });
|
||||
const { reservations } = useTripStore.getState();
|
||||
expect(reservations[0].name).toBe('Hotel Lyon');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-RESERV-004: reservation:deleted removes reservation by ID', () => {
|
||||
seedData();
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'reservation:deleted', reservationId: 1 });
|
||||
const { reservations } = useTripStore.getState();
|
||||
expect(reservations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-RESERV-005: reservation:created ordering — newest is first', () => {
|
||||
seedData();
|
||||
const r2 = buildReservation({ id: 2, name: 'Second' });
|
||||
const r3 = buildReservation({ id: 3, name: 'Third' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: r2 });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: r3 });
|
||||
const { reservations } = useTripStore.getState();
|
||||
expect(reservations[0].id).toBe(3);
|
||||
expect(reservations[1].id).toBe(2);
|
||||
expect(reservations[2].id).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildTodoItem } from '../../helpers/factories';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('remoteEventHandler > todo', () => {
|
||||
const seedData = () => {
|
||||
useTripStore.setState({
|
||||
todoItems: [buildTodoItem({ id: 1, name: 'Book flights' })],
|
||||
});
|
||||
};
|
||||
|
||||
it('FE-WSEVT-TODO-001: todo:created adds item to todoItems', () => {
|
||||
seedData();
|
||||
const newItem = buildTodoItem({ id: 99, name: 'Pack bags' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'todo:created', item: newItem });
|
||||
const { todoItems } = useTripStore.getState();
|
||||
expect(todoItems).toHaveLength(2);
|
||||
expect(todoItems.find(i => i.id === 99)).toBeDefined();
|
||||
});
|
||||
|
||||
it('FE-WSEVT-TODO-002: todo:created is idempotent — no duplicate if same ID', () => {
|
||||
seedData();
|
||||
const duplicate = buildTodoItem({ id: 1, name: 'Book flights duplicate' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'todo:created', item: duplicate });
|
||||
const { todoItems } = useTripStore.getState();
|
||||
expect(todoItems).toHaveLength(1);
|
||||
expect(todoItems[0].name).toBe('Book flights');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-TODO-003: todo:updated replaces item in array', () => {
|
||||
seedData();
|
||||
const updated = buildTodoItem({ id: 1, name: 'Book round-trip flights' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'todo:updated', item: updated });
|
||||
const { todoItems } = useTripStore.getState();
|
||||
expect(todoItems[0].name).toBe('Book round-trip flights');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-TODO-004: todo:deleted removes item by ID', () => {
|
||||
seedData();
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'todo:deleted', itemId: 1 });
|
||||
const { todoItems } = useTripStore.getState();
|
||||
expect(todoItems).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildTrip, buildPlace } from '../../helpers/factories';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('remoteEventHandler > trip', () => {
|
||||
it('FE-WSEVT-TRIP-001: trip:updated replaces trip in state', () => {
|
||||
const originalTrip = buildTrip({ id: 1, name: 'Paris Trip' });
|
||||
useTripStore.setState({ trip: originalTrip });
|
||||
const updatedTrip = buildTrip({ id: 1, name: 'Paris & Lyon Trip' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'trip:updated', trip: updatedTrip });
|
||||
const { trip } = useTripStore.getState();
|
||||
expect(trip?.name).toBe('Paris & Lyon Trip');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-TRIP-002: trip:updated does not affect other state fields', () => {
|
||||
const existingPlace = buildPlace({ id: 55, name: 'Eiffel Tower' });
|
||||
useTripStore.setState({
|
||||
trip: buildTrip({ id: 1, name: 'Original' }),
|
||||
places: [existingPlace],
|
||||
});
|
||||
const updatedTrip = buildTrip({ id: 1, name: 'Updated' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'trip:updated', trip: updatedTrip });
|
||||
const { places } = useTripStore.getState();
|
||||
expect(places).toHaveLength(1);
|
||||
expect(places[0].id).toBe(55);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores, seedStore } from '../../helpers/store';
|
||||
import { buildPlace, buildAssignment } from '../../helpers/factories';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
|
||||
vi.mock('../../../src/api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
joinTrip: vi.fn(),
|
||||
leaveTrip: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('assignmentsSlice', () => {
|
||||
describe('assignPlaceToDay', () => {
|
||||
it('FE-ASSIGN-001: assignPlaceToDay adds optimistic temp ID (negative) immediately', async () => {
|
||||
const place = buildPlace({ id: 10, trip_id: 1 });
|
||||
seedStore(useTripStore, {
|
||||
places: [place],
|
||||
assignments: { '1': [] },
|
||||
});
|
||||
|
||||
// Don't await — check state mid-flight
|
||||
let tempAdded = false;
|
||||
server.use(
|
||||
http.post('/api/trips/1/days/1/assignments', async () => {
|
||||
const state = useTripStore.getState();
|
||||
const dayAssignments = state.assignments['1'];
|
||||
if (dayAssignments.some(a => a.id < 0)) {
|
||||
tempAdded = true;
|
||||
}
|
||||
const result = buildAssignment({ day_id: 1, place_id: 10, place });
|
||||
return HttpResponse.json({ assignment: result });
|
||||
}),
|
||||
);
|
||||
|
||||
await useTripStore.getState().assignPlaceToDay(1, 1, 10);
|
||||
expect(tempAdded).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ASSIGN-002: after API success, temp ID is replaced with real assignment', async () => {
|
||||
const place = buildPlace({ id: 10, trip_id: 1 });
|
||||
seedStore(useTripStore, {
|
||||
places: [place],
|
||||
assignments: { '1': [] },
|
||||
});
|
||||
|
||||
const realAssignment = buildAssignment({ id: 999, day_id: 1, place_id: 10, place });
|
||||
server.use(
|
||||
http.post('/api/trips/1/days/1/assignments', () =>
|
||||
HttpResponse.json({ assignment: realAssignment })
|
||||
),
|
||||
);
|
||||
|
||||
await useTripStore.getState().assignPlaceToDay(1, 1, 10);
|
||||
|
||||
const dayAssignments = useTripStore.getState().assignments['1'];
|
||||
expect(dayAssignments).toHaveLength(1);
|
||||
expect(dayAssignments[0].id).toBe(999);
|
||||
expect(dayAssignments.every(a => a.id > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ASSIGN-003: on API failure, temp assignment is removed (rollback)', async () => {
|
||||
const place = buildPlace({ id: 10, trip_id: 1 });
|
||||
seedStore(useTripStore, {
|
||||
places: [place],
|
||||
assignments: { '1': [] },
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.post('/api/trips/1/days/1/assignments', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
await expect(useTripStore.getState().assignPlaceToDay(1, 1, 10)).rejects.toThrow();
|
||||
|
||||
const dayAssignments = useTripStore.getState().assignments['1'];
|
||||
expect(dayAssignments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-ASSIGN-001b: returns undefined if place not found in store', async () => {
|
||||
seedStore(useTripStore, {
|
||||
places: [], // no places seeded
|
||||
assignments: { '1': [] },
|
||||
});
|
||||
|
||||
const result = await useTripStore.getState().assignPlaceToDay(1, 1, 999);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAssignment', () => {
|
||||
it('FE-ASSIGN-004: removeAssignment is optimistically removed, re-added on failure', async () => {
|
||||
const place = buildPlace({ id: 10, trip_id: 1 });
|
||||
const assignment = buildAssignment({ id: 100, day_id: 1, place });
|
||||
seedStore(useTripStore, {
|
||||
assignments: { '1': [assignment] },
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.delete('/api/trips/1/days/1/assignments/100', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
await expect(useTripStore.getState().removeAssignment(1, 1, 100)).rejects.toThrow();
|
||||
|
||||
// Should be rolled back
|
||||
const dayAssignments = useTripStore.getState().assignments['1'];
|
||||
expect(dayAssignments).toHaveLength(1);
|
||||
expect(dayAssignments[0].id).toBe(100);
|
||||
});
|
||||
|
||||
it('FE-ASSIGN-004b: removeAssignment success removes from store', async () => {
|
||||
const place = buildPlace({ id: 10, trip_id: 1 });
|
||||
const assignment = buildAssignment({ id: 100, day_id: 1, place });
|
||||
seedStore(useTripStore, {
|
||||
assignments: { '1': [assignment] },
|
||||
});
|
||||
|
||||
await useTripStore.getState().removeAssignment(1, 1, 100);
|
||||
|
||||
expect(useTripStore.getState().assignments['1']).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderAssignments', () => {
|
||||
it('FE-ASSIGN-005: reorderAssignments updates order_index of assignments', async () => {
|
||||
const place1 = buildPlace({ id: 10 });
|
||||
const place2 = buildPlace({ id: 20 });
|
||||
const a1 = buildAssignment({ id: 1, day_id: 5, order_index: 0, place: place1 });
|
||||
const a2 = buildAssignment({ id: 2, day_id: 5, order_index: 1, place: place2 });
|
||||
seedStore(useTripStore, {
|
||||
assignments: { '5': [a1, a2] },
|
||||
});
|
||||
|
||||
await useTripStore.getState().reorderAssignments(1, 5, [2, 1]);
|
||||
|
||||
const dayAssignments = useTripStore.getState().assignments['5'];
|
||||
const reorderedA2 = dayAssignments.find(a => a.id === 2);
|
||||
const reorderedA1 = dayAssignments.find(a => a.id === 1);
|
||||
expect(reorderedA2?.order_index).toBe(0);
|
||||
expect(reorderedA1?.order_index).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-ASSIGN-005b: reorderAssignments rolls back on failure', async () => {
|
||||
const place1 = buildPlace({ id: 10 });
|
||||
const place2 = buildPlace({ id: 20 });
|
||||
const a1 = buildAssignment({ id: 1, day_id: 5, order_index: 0, place: place1 });
|
||||
const a2 = buildAssignment({ id: 2, day_id: 5, order_index: 1, place: place2 });
|
||||
seedStore(useTripStore, {
|
||||
assignments: { '5': [a1, a2] },
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/days/5/assignments/reorder', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
await expect(useTripStore.getState().reorderAssignments(1, 5, [2, 1])).rejects.toThrow();
|
||||
|
||||
const dayAssignments = useTripStore.getState().assignments['5'];
|
||||
expect(dayAssignments.find(a => a.id === 1)?.order_index).toBe(0);
|
||||
expect(dayAssignments.find(a => a.id === 2)?.order_index).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveAssignment', () => {
|
||||
it('FE-ASSIGN-006: moveAssignment removes from source day and adds to target day', async () => {
|
||||
const place = buildPlace({ id: 10 });
|
||||
const assignment = buildAssignment({ id: 50, day_id: 1, order_index: 0, place });
|
||||
seedStore(useTripStore, {
|
||||
assignments: {
|
||||
'1': [assignment],
|
||||
'2': [],
|
||||
},
|
||||
});
|
||||
|
||||
await useTripStore.getState().moveAssignment(1, 50, 1, 2);
|
||||
|
||||
expect(useTripStore.getState().assignments['1']).toHaveLength(0);
|
||||
expect(useTripStore.getState().assignments['2']).toHaveLength(1);
|
||||
expect(useTripStore.getState().assignments['2'][0].id).toBe(50);
|
||||
});
|
||||
|
||||
it('FE-ASSIGN-007: moveAssignment rolls back on failure', async () => {
|
||||
const place = buildPlace({ id: 10 });
|
||||
const assignment = buildAssignment({ id: 50, day_id: 1, order_index: 0, place });
|
||||
seedStore(useTripStore, {
|
||||
assignments: {
|
||||
'1': [assignment],
|
||||
'2': [],
|
||||
},
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/assignments/50/move', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
await expect(useTripStore.getState().moveAssignment(1, 50, 1, 2)).rejects.toThrow();
|
||||
|
||||
// Rolled back: assignment back in day 1
|
||||
expect(useTripStore.getState().assignments['1']).toHaveLength(1);
|
||||
expect(useTripStore.getState().assignments['1'][0].id).toBe(50);
|
||||
expect(useTripStore.getState().assignments['2']).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores, seedStore } from '../../helpers/store';
|
||||
import { buildBudgetItem, buildReservation } from '../../helpers/factories';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
|
||||
vi.mock('../../../src/api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
joinTrip: vi.fn(),
|
||||
leaveTrip: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('budgetSlice', () => {
|
||||
describe('loadBudgetItems', () => {
|
||||
it('FE-BUDGET-001: loadBudgetItems fetches and replaces budgetItems', async () => {
|
||||
seedStore(useTripStore, { budgetItems: [] });
|
||||
|
||||
const item = buildBudgetItem({ trip_id: 1 });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
);
|
||||
|
||||
await useTripStore.getState().loadBudgetItems(1);
|
||||
|
||||
expect(useTripStore.getState().budgetItems).toHaveLength(1);
|
||||
expect(useTripStore.getState().budgetItems[0].id).toBe(item.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addBudgetItem', () => {
|
||||
it('FE-BUDGET-002: addBudgetItem appends to budgetItems', async () => {
|
||||
const existing = buildBudgetItem({ trip_id: 1 });
|
||||
seedStore(useTripStore, { budgetItems: [existing] });
|
||||
|
||||
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Hotel', amount: 200 });
|
||||
|
||||
expect(result.name).toBe('Hotel');
|
||||
expect(useTripStore.getState().budgetItems).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-BUDGET-003: addBudgetItem on failure throws', async () => {
|
||||
server.use(
|
||||
http.post('/api/trips/1/budget', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
useTripStore.getState().addBudgetItem(1, { name: 'Fail' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateBudgetItem', () => {
|
||||
it('FE-BUDGET-004: updateBudgetItem replaces item in array', async () => {
|
||||
const item = buildBudgetItem({ id: 10, trip_id: 1, name: 'Old', amount: 100 });
|
||||
seedStore(useTripStore, { budgetItems: [item] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/budget/10', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ item: { ...item, ...body } });
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await useTripStore.getState().updateBudgetItem(1, 10, { name: 'Updated', amount: 150 });
|
||||
|
||||
expect(result.name).toBe('Updated');
|
||||
expect(useTripStore.getState().budgetItems[0].name).toBe('Updated');
|
||||
});
|
||||
|
||||
it('FE-BUDGET-005: updateBudgetItem with total_price triggers loadReservations when reservation_id present', async () => {
|
||||
const item = buildBudgetItem({ id: 10, trip_id: 1, amount: 100 });
|
||||
const initialReservation = buildReservation({ trip_id: 1 });
|
||||
const newReservation = buildReservation({ trip_id: 1, name: 'Refreshed Reservation' });
|
||||
seedStore(useTripStore, {
|
||||
budgetItems: [item],
|
||||
reservations: [initialReservation],
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/budget/10', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
// Return item with reservation_id to trigger loadReservations
|
||||
return HttpResponse.json({ item: { ...item, ...body, reservation_id: 42 } });
|
||||
}),
|
||||
http.get('/api/trips/1/reservations', () =>
|
||||
HttpResponse.json({ reservations: [newReservation] })
|
||||
),
|
||||
);
|
||||
|
||||
await useTripStore.getState().updateBudgetItem(1, 10, { total_price: 200 } as Record<string, unknown>);
|
||||
|
||||
// Wait for the async loadReservations to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
expect(useTripStore.getState().reservations).toHaveLength(1);
|
||||
expect(useTripStore.getState().reservations[0].name).toBe('Refreshed Reservation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteBudgetItem', () => {
|
||||
it('FE-BUDGET-006: deleteBudgetItem optimistically removes item, rolls back on failure', async () => {
|
||||
const item = buildBudgetItem({ id: 10, trip_id: 1 });
|
||||
seedStore(useTripStore, { budgetItems: [item] });
|
||||
|
||||
server.use(
|
||||
http.delete('/api/trips/1/budget/10', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
await expect(useTripStore.getState().deleteBudgetItem(1, 10)).rejects.toThrow();
|
||||
|
||||
expect(useTripStore.getState().budgetItems).toHaveLength(1);
|
||||
expect(useTripStore.getState().budgetItems[0].id).toBe(10);
|
||||
});
|
||||
|
||||
it('FE-BUDGET-006b: deleteBudgetItem success removes item', async () => {
|
||||
const item1 = buildBudgetItem({ id: 10, trip_id: 1 });
|
||||
const item2 = buildBudgetItem({ id: 20, trip_id: 1 });
|
||||
seedStore(useTripStore, { budgetItems: [item1, item2] });
|
||||
|
||||
await useTripStore.getState().deleteBudgetItem(1, 10);
|
||||
|
||||
expect(useTripStore.getState().budgetItems).toHaveLength(1);
|
||||
expect(useTripStore.getState().budgetItems[0].id).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setBudgetItemMembers', () => {
|
||||
it('FE-BUDGET-007: setBudgetItemMembers updates members array on item', async () => {
|
||||
const item = buildBudgetItem({ id: 10, trip_id: 1, members: [] });
|
||||
seedStore(useTripStore, { budgetItems: [item] });
|
||||
|
||||
const members = [{ user_id: 1, paid: false }, { user_id: 2, paid: false }];
|
||||
server.use(
|
||||
http.put('/api/trips/1/budget/10/members', () =>
|
||||
HttpResponse.json({ members, item: { ...item, persons: 2, members } })
|
||||
),
|
||||
);
|
||||
|
||||
const result = await useTripStore.getState().setBudgetItemMembers(1, 10, [1, 2]);
|
||||
|
||||
expect(result.members).toHaveLength(2);
|
||||
const updatedItem = useTripStore.getState().budgetItems.find(i => i.id === 10);
|
||||
expect(updatedItem?.members).toHaveLength(2);
|
||||
expect(updatedItem?.persons).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleBudgetMemberPaid', () => {
|
||||
it('FE-BUDGET-008: toggleBudgetMemberPaid updates paid status after API success', async () => {
|
||||
const member = { user_id: 5, paid: false };
|
||||
const item = buildBudgetItem({ id: 10, trip_id: 1, members: [member] });
|
||||
seedStore(useTripStore, { budgetItems: [item] });
|
||||
|
||||
await useTripStore.getState().toggleBudgetMemberPaid(1, 10, 5, true);
|
||||
|
||||
const updatedItem = useTripStore.getState().budgetItems.find(i => i.id === 10);
|
||||
const updatedMember = updatedItem?.members.find(m => m.user_id === 5);
|
||||
expect(updatedMember?.paid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores, seedStore } from '../../helpers/store';
|
||||
import { buildDay, buildDayNote } from '../../helpers/factories';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
|
||||
vi.mock('../../../src/api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
joinTrip: vi.fn(),
|
||||
leaveTrip: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('dayNotesSlice', () => {
|
||||
describe('addDayNote', () => {
|
||||
it('FE-DAYNOTES-001: addDayNote inserts temp note immediately, replaces on success', async () => {
|
||||
seedStore(useTripStore, { dayNotes: { '1': [] } });
|
||||
|
||||
let tempAdded = false;
|
||||
const realNote = buildDayNote({ id: 500, day_id: 1, text: 'New note' });
|
||||
|
||||
server.use(
|
||||
http.post('/api/trips/1/days/1/notes', async () => {
|
||||
const state = useTripStore.getState();
|
||||
const notes = state.dayNotes['1'];
|
||||
if (notes.some(n => n.id < 0)) {
|
||||
tempAdded = true;
|
||||
}
|
||||
return HttpResponse.json({ note: realNote });
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await useTripStore.getState().addDayNote(1, 1, { text: 'New note', sort_order: 0 });
|
||||
|
||||
expect(tempAdded).toBe(true);
|
||||
expect(result.id).toBe(500);
|
||||
const notes = useTripStore.getState().dayNotes['1'];
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0].id).toBe(500);
|
||||
});
|
||||
|
||||
it('FE-DAYNOTES-002: addDayNote on failure rolls back — temp note removed', async () => {
|
||||
seedStore(useTripStore, { dayNotes: { '1': [] } });
|
||||
|
||||
server.use(
|
||||
http.post('/api/trips/1/days/1/notes', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
useTripStore.getState().addDayNote(1, 1, { text: 'Fail note', sort_order: 0 })
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(useTripStore.getState().dayNotes['1']).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDayNote', () => {
|
||||
it('FE-DAYNOTES-003: updateDayNote replaces note in map by id', async () => {
|
||||
const note = buildDayNote({ id: 10, day_id: 1, text: 'Old text' });
|
||||
seedStore(useTripStore, { dayNotes: { '1': [note] } });
|
||||
|
||||
const updated = { ...note, text: 'Updated text' };
|
||||
server.use(
|
||||
http.put('/api/trips/1/days/1/notes/10', () =>
|
||||
HttpResponse.json({ note: updated })
|
||||
),
|
||||
);
|
||||
|
||||
const result = await useTripStore.getState().updateDayNote(1, 1, 10, { text: 'Updated text' });
|
||||
|
||||
expect(result.text).toBe('Updated text');
|
||||
expect(useTripStore.getState().dayNotes['1'][0].text).toBe('Updated text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteDayNote', () => {
|
||||
it('FE-DAYNOTES-004: deleteDayNote optimistically removes note, restores on failure', async () => {
|
||||
const note = buildDayNote({ id: 10, day_id: 1 });
|
||||
seedStore(useTripStore, { dayNotes: { '1': [note] } });
|
||||
|
||||
server.use(
|
||||
http.delete('/api/trips/1/days/1/notes/10', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
await expect(useTripStore.getState().deleteDayNote(1, 1, 10)).rejects.toThrow();
|
||||
|
||||
// Rolled back
|
||||
expect(useTripStore.getState().dayNotes['1']).toHaveLength(1);
|
||||
expect(useTripStore.getState().dayNotes['1'][0].id).toBe(10);
|
||||
});
|
||||
|
||||
it('FE-DAYNOTES-004b: deleteDayNote success removes note from correct day', async () => {
|
||||
const note1 = buildDayNote({ id: 10, day_id: 1 });
|
||||
const note2 = buildDayNote({ id: 20, day_id: 1 });
|
||||
seedStore(useTripStore, { dayNotes: { '1': [note1, note2] } });
|
||||
|
||||
await useTripStore.getState().deleteDayNote(1, 1, 10);
|
||||
|
||||
const notes = useTripStore.getState().dayNotes['1'];
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0].id).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveDayNote', () => {
|
||||
it('FE-DAYNOTES-005: moveDayNote removes from source, adds to target (delete+create)', async () => {
|
||||
const note = buildDayNote({ id: 10, day_id: 1, text: 'Move me' });
|
||||
const newNote = buildDayNote({ id: 99, day_id: 2, text: 'Move me' });
|
||||
seedStore(useTripStore, { dayNotes: { '1': [note], '2': [] } });
|
||||
|
||||
server.use(
|
||||
http.delete('/api/trips/1/days/1/notes/10', () => HttpResponse.json({ success: true })),
|
||||
http.post('/api/trips/1/days/2/notes', () => HttpResponse.json({ note: newNote })),
|
||||
);
|
||||
|
||||
await useTripStore.getState().moveDayNote(1, 1, 2, 10);
|
||||
|
||||
expect(useTripStore.getState().dayNotes['1']).toHaveLength(0);
|
||||
expect(useTripStore.getState().dayNotes['2']).toHaveLength(1);
|
||||
expect(useTripStore.getState().dayNotes['2'][0].id).toBe(99);
|
||||
});
|
||||
|
||||
it('FE-DAYNOTES-006: moveDayNote rolls back to source day on failure', async () => {
|
||||
const note = buildDayNote({ id: 10, day_id: 1, text: 'Move me' });
|
||||
seedStore(useTripStore, { dayNotes: { '1': [note], '2': [] } });
|
||||
|
||||
server.use(
|
||||
http.delete('/api/trips/1/days/1/notes/10', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
await expect(useTripStore.getState().moveDayNote(1, 1, 2, 10)).rejects.toThrow();
|
||||
|
||||
expect(useTripStore.getState().dayNotes['1']).toHaveLength(1);
|
||||
expect(useTripStore.getState().dayNotes['1'][0].id).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDayNotes', () => {
|
||||
it('FE-DAYNOTES-007: updateDayNotes persists notes text and updates days array', async () => {
|
||||
const day = buildDay({ id: 1, trip_id: 1, notes: null });
|
||||
seedStore(useTripStore, { days: [day] });
|
||||
|
||||
await useTripStore.getState().updateDayNotes(1, 1, 'My travel notes');
|
||||
|
||||
const updatedDay = useTripStore.getState().days.find(d => d.id === 1);
|
||||
expect(updatedDay?.notes).toBe('My travel notes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDayTitle', () => {
|
||||
it('FE-DAYNOTES-008: updateDayTitle persists title and updates days array', async () => {
|
||||
const day = buildDay({ id: 1, trip_id: 1, title: null });
|
||||
seedStore(useTripStore, { days: [day] });
|
||||
|
||||
await useTripStore.getState().updateDayTitle(1, 1, 'Day at the Beach');
|
||||
|
||||
const updatedDay = useTripStore.getState().days.find(d => d.id === 1);
|
||||
expect(updatedDay?.title).toBe('Day at the Beach');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores, seedStore } from '../../helpers/store';
|
||||
import { buildTripFile } from '../../helpers/factories';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
|
||||
vi.mock('../../../src/api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
joinTrip: vi.fn(),
|
||||
leaveTrip: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('filesSlice', () => {
|
||||
describe('loadFiles', () => {
|
||||
it('FE-FILES-001: loadFiles fetches and replaces files array', async () => {
|
||||
const staleFile = buildTripFile({ trip_id: 1, filename: 'stale.pdf' });
|
||||
seedStore(useTripStore, { files: [staleFile] });
|
||||
|
||||
const freshFile = buildTripFile({ trip_id: 1, filename: 'fresh.pdf' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [freshFile] })),
|
||||
);
|
||||
|
||||
await useTripStore.getState().loadFiles(1);
|
||||
|
||||
const files = useTripStore.getState().files;
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0].filename).toBe('fresh.pdf');
|
||||
});
|
||||
|
||||
it('FE-FILES-002: loadFiles silently catches errors', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/files', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
// Should not throw
|
||||
await useTripStore.getState().loadFiles(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addFile', () => {
|
||||
it('FE-FILES-003: addFile uploads and prepends file to files array', async () => {
|
||||
const existing = buildTripFile({ trip_id: 1, filename: 'existing.pdf' });
|
||||
seedStore(useTripStore, { files: [existing] });
|
||||
|
||||
const uploaded = buildTripFile({ trip_id: 1, filename: 'new-upload.pdf' });
|
||||
server.use(
|
||||
http.post('/api/trips/1/files', () => HttpResponse.json({ file: uploaded })),
|
||||
);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob(['content'], { type: 'application/pdf' }), 'new-upload.pdf');
|
||||
|
||||
const result = await useTripStore.getState().addFile(1, formData);
|
||||
|
||||
expect(result.filename).toBe('new-upload.pdf');
|
||||
const files = useTripStore.getState().files;
|
||||
expect(files).toHaveLength(2);
|
||||
// prepends
|
||||
expect(files[0].filename).toBe('new-upload.pdf');
|
||||
});
|
||||
|
||||
it('FE-FILES-004: addFile on failure throws', async () => {
|
||||
server.use(
|
||||
http.post('/api/trips/1/files', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
await expect(useTripStore.getState().addFile(1, formData)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFile', () => {
|
||||
it('FE-FILES-005: deleteFile removes file from array after API success', async () => {
|
||||
const file1 = buildTripFile({ id: 10, trip_id: 1 });
|
||||
const file2 = buildTripFile({ id: 20, trip_id: 1 });
|
||||
seedStore(useTripStore, { files: [file1, file2] });
|
||||
|
||||
await useTripStore.getState().deleteFile(1, 10);
|
||||
|
||||
const files = useTripStore.getState().files;
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0].id).toBe(20);
|
||||
});
|
||||
|
||||
it('FE-FILES-006: deleteFile on failure throws', async () => {
|
||||
const file = buildTripFile({ id: 10, trip_id: 1 });
|
||||
seedStore(useTripStore, { files: [file] });
|
||||
|
||||
server.use(
|
||||
http.delete('/api/trips/1/files/10', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
await expect(useTripStore.getState().deleteFile(1, 10)).rejects.toThrow();
|
||||
|
||||
// File remains since server-first (only removes after success)
|
||||
expect(useTripStore.getState().files).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores, seedStore } from '../../helpers/store';
|
||||
import { buildPackingItem } from '../../helpers/factories';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
|
||||
vi.mock('../../../src/api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
joinTrip: vi.fn(),
|
||||
leaveTrip: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('packingSlice', () => {
|
||||
describe('addPackingItem', () => {
|
||||
it('FE-PACKING-001: addPackingItem calls API and appends item to packingItems', async () => {
|
||||
const existing = buildPackingItem({ trip_id: 1, name: 'Existing' });
|
||||
seedStore(useTripStore, { packingItems: [existing] });
|
||||
|
||||
const result = await useTripStore.getState().addPackingItem(1, { name: 'Toothbrush', quantity: 1 });
|
||||
|
||||
expect(result.name).toBe('Toothbrush');
|
||||
const items = useTripStore.getState().packingItems;
|
||||
expect(items).toHaveLength(2);
|
||||
// addPackingItem appends (not prepends)
|
||||
expect(items[items.length - 1].name).toBe('Toothbrush');
|
||||
});
|
||||
|
||||
it('FE-PACKING-002: addPackingItem on failure throws', async () => {
|
||||
server.use(
|
||||
http.post('/api/trips/1/packing', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
useTripStore.getState().addPackingItem(1, { name: 'Fail item' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePackingItem', () => {
|
||||
it('FE-PACKING-003: updatePackingItem replaces item in array by id', async () => {
|
||||
const item = buildPackingItem({ id: 10, trip_id: 1, name: 'Old name', quantity: 1 });
|
||||
seedStore(useTripStore, { packingItems: [item] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/10', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ item: { ...item, ...body } });
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await useTripStore.getState().updatePackingItem(1, 10, { name: 'New name' });
|
||||
|
||||
expect(result.name).toBe('New name');
|
||||
expect(useTripStore.getState().packingItems[0].name).toBe('New name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletePackingItem', () => {
|
||||
it('FE-PACKING-004: deletePackingItem optimistically removes item, rollback on failure', async () => {
|
||||
const item = buildPackingItem({ id: 10, trip_id: 1 });
|
||||
seedStore(useTripStore, { packingItems: [item] });
|
||||
|
||||
server.use(
|
||||
http.delete('/api/trips/1/packing/10', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
await expect(useTripStore.getState().deletePackingItem(1, 10)).rejects.toThrow();
|
||||
|
||||
expect(useTripStore.getState().packingItems).toHaveLength(1);
|
||||
expect(useTripStore.getState().packingItems[0].id).toBe(10);
|
||||
});
|
||||
|
||||
it('FE-PACKING-004b: deletePackingItem success removes item', async () => {
|
||||
const item1 = buildPackingItem({ id: 10, trip_id: 1 });
|
||||
const item2 = buildPackingItem({ id: 20, trip_id: 1 });
|
||||
seedStore(useTripStore, { packingItems: [item1, item2] });
|
||||
|
||||
await useTripStore.getState().deletePackingItem(1, 10);
|
||||
|
||||
const items = useTripStore.getState().packingItems;
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].id).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('togglePackingItem', () => {
|
||||
it('FE-PACKING-005: togglePackingItem sets checked optimistically', async () => {
|
||||
const item = buildPackingItem({ id: 10, trip_id: 1, checked: 0 });
|
||||
seedStore(useTripStore, { packingItems: [item] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/10', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ item: { ...item, ...body } });
|
||||
}),
|
||||
);
|
||||
|
||||
await useTripStore.getState().togglePackingItem(1, 10, true);
|
||||
|
||||
expect(useTripStore.getState().packingItems[0].checked).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-PACKING-006: togglePackingItem rolls back checked on API failure', async () => {
|
||||
const item = buildPackingItem({ id: 10, trip_id: 1, checked: 0 });
|
||||
seedStore(useTripStore, { packingItems: [item] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/10', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
// toggle does NOT throw on error (silent rollback)
|
||||
await useTripStore.getState().togglePackingItem(1, 10, true);
|
||||
|
||||
// Should be rolled back to original value
|
||||
expect(useTripStore.getState().packingItems[0].checked).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores, seedStore } from '../../helpers/store';
|
||||
import { buildPlace, buildAssignment } from '../../helpers/factories';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
|
||||
vi.mock('../../../src/api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
joinTrip: vi.fn(),
|
||||
leaveTrip: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('placesSlice', () => {
|
||||
describe('addPlace', () => {
|
||||
it('FE-PLACES-001: addPlace calls API and prepends place to places array', async () => {
|
||||
const existing = buildPlace({ trip_id: 1 });
|
||||
seedStore(useTripStore, { places: [existing] });
|
||||
|
||||
const result = await useTripStore.getState().addPlace(1, { name: 'New Place' });
|
||||
|
||||
expect(result.name).toBe('New Place');
|
||||
const places = useTripStore.getState().places;
|
||||
expect(places).toHaveLength(2);
|
||||
expect(places[0].name).toBe('New Place'); // prepended
|
||||
});
|
||||
|
||||
it('FE-PLACES-002: addPlace on failure throws and places remain unchanged', async () => {
|
||||
const existing = buildPlace({ trip_id: 1 });
|
||||
seedStore(useTripStore, { places: [existing] });
|
||||
|
||||
server.use(
|
||||
http.post('/api/trips/:id/places', () =>
|
||||
HttpResponse.json({ message: 'Server error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
await expect(useTripStore.getState().addPlace(1, { name: 'Fail' })).rejects.toThrow();
|
||||
expect(useTripStore.getState().places).toEqual([existing]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePlace', () => {
|
||||
it('FE-PLACES-003: updatePlace calls API and updates place in array', async () => {
|
||||
const place = buildPlace({ id: 10, trip_id: 1, name: 'Old Name' });
|
||||
seedStore(useTripStore, { places: [place] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/:id/places/:placeId', async ({ params, request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ place: { ...place, ...body, id: Number(params.placeId) } });
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await useTripStore.getState().updatePlace(1, 10, { name: 'New Name' });
|
||||
|
||||
expect(result.name).toBe('New Name');
|
||||
const updated = useTripStore.getState().places.find(p => p.id === 10);
|
||||
expect(updated?.name).toBe('New Name');
|
||||
});
|
||||
|
||||
it('FE-PLACES-004: updatePlace cascades to assignments map — assignment place field updated', async () => {
|
||||
const place = buildPlace({ id: 10, trip_id: 1, name: 'Old Place' });
|
||||
const assignment = buildAssignment({ id: 100, day_id: 1, place });
|
||||
seedStore(useTripStore, {
|
||||
places: [place],
|
||||
assignments: { '1': [assignment] },
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/places/10', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ place: { ...place, ...body } });
|
||||
}),
|
||||
);
|
||||
|
||||
await useTripStore.getState().updatePlace(1, 10, { name: 'Updated Place' });
|
||||
|
||||
const updatedAssignments = useTripStore.getState().assignments['1'];
|
||||
expect(updatedAssignments[0].place.name).toBe('Updated Place');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletePlace', () => {
|
||||
it('FE-PLACES-005: deletePlace removes place from places array', async () => {
|
||||
const place1 = buildPlace({ id: 10, trip_id: 1 });
|
||||
const place2 = buildPlace({ id: 20, trip_id: 1 });
|
||||
seedStore(useTripStore, { places: [place1, place2], assignments: {} });
|
||||
|
||||
server.use(
|
||||
http.delete('/api/trips/1/places/10', () => HttpResponse.json({ success: true })),
|
||||
);
|
||||
|
||||
await useTripStore.getState().deletePlace(1, 10);
|
||||
|
||||
const places = useTripStore.getState().places;
|
||||
expect(places).toHaveLength(1);
|
||||
expect(places[0].id).toBe(20);
|
||||
});
|
||||
|
||||
it('FE-PLACES-006: deletePlace cascades — assignments referencing the place are removed', async () => {
|
||||
const place = buildPlace({ id: 10, trip_id: 1 });
|
||||
const otherPlace = buildPlace({ id: 20, trip_id: 1 });
|
||||
const assignmentWithPlace = buildAssignment({ id: 100, day_id: 1, place });
|
||||
const assignmentOther = buildAssignment({ id: 200, day_id: 1, place: otherPlace });
|
||||
|
||||
seedStore(useTripStore, {
|
||||
places: [place, otherPlace],
|
||||
assignments: { '1': [assignmentWithPlace, assignmentOther] },
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.delete('/api/trips/1/places/10', () => HttpResponse.json({ success: true })),
|
||||
);
|
||||
|
||||
await useTripStore.getState().deletePlace(1, 10);
|
||||
|
||||
const dayAssignments = useTripStore.getState().assignments['1'];
|
||||
expect(dayAssignments).toHaveLength(1);
|
||||
expect(dayAssignments[0].id).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshPlaces', () => {
|
||||
it('FE-PLACES-007: refreshPlaces re-fetches and replaces places array', async () => {
|
||||
const stale = buildPlace({ id: 99, trip_id: 1, name: 'Stale' });
|
||||
seedStore(useTripStore, { places: [stale] });
|
||||
|
||||
const fresh = buildPlace({ trip_id: 1, name: 'Fresh' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/places', () => HttpResponse.json({ places: [fresh] })),
|
||||
);
|
||||
|
||||
await useTripStore.getState().refreshPlaces(1);
|
||||
|
||||
const places = useTripStore.getState().places;
|
||||
expect(places).toHaveLength(1);
|
||||
expect(places[0].name).toBe('Fresh');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores, seedStore } from '../../helpers/store';
|
||||
import { buildReservation } from '../../helpers/factories';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
|
||||
vi.mock('../../../src/api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
joinTrip: vi.fn(),
|
||||
leaveTrip: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('reservationsSlice', () => {
|
||||
describe('loadReservations', () => {
|
||||
it('FE-RESERV-001: loadReservations fetches and replaces reservations', async () => {
|
||||
seedStore(useTripStore, { reservations: [] });
|
||||
|
||||
const reservation = buildReservation({ trip_id: 1 });
|
||||
server.use(
|
||||
http.get('/api/trips/1/reservations', () =>
|
||||
HttpResponse.json({ reservations: [reservation] })
|
||||
),
|
||||
);
|
||||
|
||||
await useTripStore.getState().loadReservations(1);
|
||||
|
||||
expect(useTripStore.getState().reservations).toHaveLength(1);
|
||||
expect(useTripStore.getState().reservations[0].id).toBe(reservation.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addReservation', () => {
|
||||
it('FE-RESERV-002: addReservation prepends to reservations array', async () => {
|
||||
const existing = buildReservation({ trip_id: 1, name: 'Existing' });
|
||||
seedStore(useTripStore, { reservations: [existing] });
|
||||
|
||||
const result = await useTripStore.getState().addReservation(1, {
|
||||
name: 'New Hotel',
|
||||
type: 'hotel',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
expect(result.name).toBe('New Hotel');
|
||||
const reservations = useTripStore.getState().reservations;
|
||||
expect(reservations).toHaveLength(2);
|
||||
// addReservation prepends
|
||||
expect(reservations[0].name).toBe('New Hotel');
|
||||
});
|
||||
|
||||
it('FE-RESERV-003: addReservation on failure throws', async () => {
|
||||
server.use(
|
||||
http.post('/api/trips/1/reservations', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
useTripStore.getState().addReservation(1, { name: 'Fail' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateReservation', () => {
|
||||
it('FE-RESERV-004: updateReservation replaces item in array by id', async () => {
|
||||
const reservation = buildReservation({ id: 10, trip_id: 1, name: 'Old', status: 'pending' });
|
||||
seedStore(useTripStore, { reservations: [reservation] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/reservations/10', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ reservation: { ...reservation, ...body } });
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await useTripStore.getState().updateReservation(1, 10, { name: 'Updated Hotel' });
|
||||
|
||||
expect(result.name).toBe('Updated Hotel');
|
||||
expect(useTripStore.getState().reservations[0].name).toBe('Updated Hotel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleReservationStatus', () => {
|
||||
it('FE-RESERV-005: toggleReservationStatus flips confirmed to pending optimistically', async () => {
|
||||
const reservation = buildReservation({ id: 10, trip_id: 1, status: 'confirmed' });
|
||||
seedStore(useTripStore, { reservations: [reservation] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/reservations/10', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ reservation: { ...reservation, ...body } });
|
||||
}),
|
||||
);
|
||||
|
||||
await useTripStore.getState().toggleReservationStatus(1, 10);
|
||||
|
||||
expect(useTripStore.getState().reservations[0].status).toBe('pending');
|
||||
});
|
||||
|
||||
it('FE-RESERV-006: toggleReservationStatus flips pending to confirmed optimistically', async () => {
|
||||
const reservation = buildReservation({ id: 10, trip_id: 1, status: 'pending' });
|
||||
seedStore(useTripStore, { reservations: [reservation] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/reservations/10', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ reservation: { ...reservation, ...body } });
|
||||
}),
|
||||
);
|
||||
|
||||
await useTripStore.getState().toggleReservationStatus(1, 10);
|
||||
|
||||
expect(useTripStore.getState().reservations[0].status).toBe('confirmed');
|
||||
});
|
||||
|
||||
it('FE-RESERV-007: toggleReservationStatus rolls back on API failure (silent)', async () => {
|
||||
const reservation = buildReservation({ id: 10, trip_id: 1, status: 'confirmed' });
|
||||
seedStore(useTripStore, { reservations: [reservation] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/reservations/10', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
// Does NOT throw (silent rollback)
|
||||
await useTripStore.getState().toggleReservationStatus(1, 10);
|
||||
|
||||
expect(useTripStore.getState().reservations[0].status).toBe('confirmed');
|
||||
});
|
||||
|
||||
it('FE-RESERV-008: toggleReservationStatus does nothing if reservation not found', async () => {
|
||||
seedStore(useTripStore, { reservations: [] });
|
||||
|
||||
// Should not throw
|
||||
await useTripStore.getState().toggleReservationStatus(1, 999);
|
||||
|
||||
expect(useTripStore.getState().reservations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteReservation', () => {
|
||||
it('FE-RESERV-009: deleteReservation removes from reservations after API success', async () => {
|
||||
const r1 = buildReservation({ id: 10, trip_id: 1 });
|
||||
const r2 = buildReservation({ id: 20, trip_id: 1 });
|
||||
seedStore(useTripStore, { reservations: [r1, r2] });
|
||||
|
||||
await useTripStore.getState().deleteReservation(1, 10);
|
||||
|
||||
const reservations = useTripStore.getState().reservations;
|
||||
expect(reservations).toHaveLength(1);
|
||||
expect(reservations[0].id).toBe(20);
|
||||
});
|
||||
|
||||
it('FE-RESERV-010: deleteReservation on failure throws (no optimistic, server-first)', async () => {
|
||||
const reservation = buildReservation({ id: 10, trip_id: 1 });
|
||||
seedStore(useTripStore, { reservations: [reservation] });
|
||||
|
||||
server.use(
|
||||
http.delete('/api/trips/1/reservations/10', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
await expect(useTripStore.getState().deleteReservation(1, 10)).rejects.toThrow();
|
||||
|
||||
// Still in state since server-first (only removes after success)
|
||||
expect(useTripStore.getState().reservations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores, seedStore } from '../../helpers/store';
|
||||
import { buildTodoItem } from '../../helpers/factories';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
|
||||
vi.mock('../../../src/api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
joinTrip: vi.fn(),
|
||||
leaveTrip: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('todoSlice', () => {
|
||||
describe('addTodoItem', () => {
|
||||
it('FE-TODO-001: addTodoItem calls API and appends item to todoItems', async () => {
|
||||
const existing = buildTodoItem({ trip_id: 1 });
|
||||
seedStore(useTripStore, { todoItems: [existing] });
|
||||
|
||||
const result = await useTripStore.getState().addTodoItem(1, { name: 'Buy sunscreen', priority: 1 });
|
||||
|
||||
expect(result.name).toBe('Buy sunscreen');
|
||||
const items = useTripStore.getState().todoItems;
|
||||
expect(items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-TODO-002: addTodoItem on failure throws', async () => {
|
||||
server.use(
|
||||
http.post('/api/trips/1/todo', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
useTripStore.getState().addTodoItem(1, { name: 'Fail' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTodoItem', () => {
|
||||
it('FE-TODO-003: updateTodoItem replaces item and preserves priority field', async () => {
|
||||
const item = buildTodoItem({ id: 10, trip_id: 1, name: 'Old', priority: 2, sort_order: 5 });
|
||||
seedStore(useTripStore, { todoItems: [item] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/todo/10', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ item: { ...item, ...body } });
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await useTripStore.getState().updateTodoItem(1, 10, { name: 'Updated', priority: 2 });
|
||||
|
||||
expect(result.name).toBe('Updated');
|
||||
expect(result.priority).toBe(2);
|
||||
expect(useTripStore.getState().todoItems[0].name).toBe('Updated');
|
||||
expect(useTripStore.getState().todoItems[0].priority).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteTodoItem', () => {
|
||||
it('FE-TODO-004: deleteTodoItem optimistically removes item, rollback on failure', async () => {
|
||||
const item = buildTodoItem({ id: 10, trip_id: 1 });
|
||||
seedStore(useTripStore, { todoItems: [item] });
|
||||
|
||||
server.use(
|
||||
http.delete('/api/trips/1/todo/10', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
await expect(useTripStore.getState().deleteTodoItem(1, 10)).rejects.toThrow();
|
||||
|
||||
expect(useTripStore.getState().todoItems).toHaveLength(1);
|
||||
expect(useTripStore.getState().todoItems[0].id).toBe(10);
|
||||
});
|
||||
|
||||
it('FE-TODO-004b: deleteTodoItem success removes item from array', async () => {
|
||||
const item1 = buildTodoItem({ id: 10, trip_id: 1 });
|
||||
const item2 = buildTodoItem({ id: 20, trip_id: 1 });
|
||||
seedStore(useTripStore, { todoItems: [item1, item2] });
|
||||
|
||||
await useTripStore.getState().deleteTodoItem(1, 10);
|
||||
|
||||
const items = useTripStore.getState().todoItems;
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].id).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleTodoItem', () => {
|
||||
it('FE-TODO-005: toggleTodoItem sets checked optimistically to 1', async () => {
|
||||
const item = buildTodoItem({ id: 10, trip_id: 1, checked: 0 });
|
||||
seedStore(useTripStore, { todoItems: [item] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/todo/10', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ item: { ...item, ...body } });
|
||||
}),
|
||||
);
|
||||
|
||||
await useTripStore.getState().toggleTodoItem(1, 10, true);
|
||||
|
||||
expect(useTripStore.getState().todoItems[0].checked).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-TODO-006: toggleTodoItem rolls back checked on API failure (silent)', async () => {
|
||||
const item = buildTodoItem({ id: 10, trip_id: 1, checked: 0 });
|
||||
seedStore(useTripStore, { todoItems: [item] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/todo/10', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
),
|
||||
);
|
||||
|
||||
// Does NOT throw
|
||||
await useTripStore.getState().toggleTodoItem(1, 10, true);
|
||||
|
||||
expect(useTripStore.getState().todoItems[0].checked).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-TODO-007: toggleTodoItem preserves sort_order field', async () => {
|
||||
const item = buildTodoItem({ id: 10, trip_id: 1, checked: 0, sort_order: 3 });
|
||||
seedStore(useTripStore, { todoItems: [item] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/todo/10', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ item: { ...item, ...body } });
|
||||
}),
|
||||
);
|
||||
|
||||
await useTripStore.getState().toggleTodoItem(1, 10, true);
|
||||
|
||||
expect(useTripStore.getState().todoItems[0].sort_order).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { useAddonStore } from '../../../src/store/addonStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('addonStore', () => {
|
||||
describe('FE-ADDON-001: loadAddons()', () => {
|
||||
it('fetches and stores enabled addons', async () => {
|
||||
await useAddonStore.getState().loadAddons();
|
||||
const state = useAddonStore.getState();
|
||||
|
||||
expect(state.loaded).toBe(true);
|
||||
expect(state.addons.length).toBeGreaterThan(0);
|
||||
expect(state.addons[0]).toHaveProperty('id');
|
||||
expect(state.addons[0]).toHaveProperty('enabled', true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-ADDON-002: isEnabled returns true for known addon', () => {
|
||||
it('returns true when addon is in the list and enabled', async () => {
|
||||
await useAddonStore.getState().loadAddons();
|
||||
expect(useAddonStore.getState().isEnabled('vacay')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-ADDON-003: isEnabled returns false for unknown addon', () => {
|
||||
it('returns false when addon is not in the list', async () => {
|
||||
await useAddonStore.getState().loadAddons();
|
||||
expect(useAddonStore.getState().isEnabled('nonexistent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-ADDON-004: API failure', () => {
|
||||
it('sets loaded: true and keeps addons empty on API error', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ error: 'Server error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
|
||||
await useAddonStore.getState().loadAddons();
|
||||
const state = useAddonStore.getState();
|
||||
|
||||
expect(state.loaded).toBe(true);
|
||||
expect(state.addons).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { useAuthStore } from '../../../src/store/authStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildUser } from '../../helpers/factories';
|
||||
|
||||
// The websocket module is already mocked globally in tests/setup.ts
|
||||
import { connect, disconnect } from '../../../src/api/websocket';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('authStore', () => {
|
||||
describe('FE-AUTH-001: Successful login', () => {
|
||||
it('sets user, isAuthenticated: true, isLoading: false', async () => {
|
||||
const user = buildUser();
|
||||
server.use(
|
||||
http.post('/api/auth/login', () =>
|
||||
HttpResponse.json({ user, token: 'tok' })
|
||||
)
|
||||
);
|
||||
|
||||
await useAuthStore.getState().login(user.email, 'password');
|
||||
const state = useAuthStore.getState();
|
||||
|
||||
expect(state.user).toEqual(user);
|
||||
expect(state.isAuthenticated).toBe(true);
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-AUTH-002: Login failure', () => {
|
||||
it('sets error and isAuthenticated: false', async () => {
|
||||
server.use(
|
||||
http.post('/api/auth/login', () =>
|
||||
HttpResponse.json({ error: 'Bad credentials' }, { status: 401 })
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
useAuthStore.getState().login('bad@example.com', 'wrong')
|
||||
).rejects.toThrow();
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.error).toBe('Bad credentials');
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-AUTH-003: Login calls connect()', () => {
|
||||
it('calls connect from websocket module after successful login', async () => {
|
||||
const user = buildUser();
|
||||
server.use(
|
||||
http.post('/api/auth/login', () =>
|
||||
HttpResponse.json({ user, token: 'tok' })
|
||||
)
|
||||
);
|
||||
|
||||
await useAuthStore.getState().login(user.email, 'password');
|
||||
|
||||
expect(connect).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-AUTH-004: loadUser with valid session', () => {
|
||||
it('sets user state from /auth/me', async () => {
|
||||
const user = buildUser();
|
||||
server.use(
|
||||
http.get('/api/auth/me', () => HttpResponse.json({ user }))
|
||||
);
|
||||
|
||||
await useAuthStore.getState().loadUser();
|
||||
const state = useAuthStore.getState();
|
||||
|
||||
expect(state.user).toEqual(user);
|
||||
expect(state.isAuthenticated).toBe(true);
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-AUTH-005: loadUser with 401', () => {
|
||||
it('clears auth state on 401', async () => {
|
||||
server.use(
|
||||
http.get('/api/auth/me', () =>
|
||||
HttpResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
)
|
||||
);
|
||||
|
||||
// Pre-seed as authenticated
|
||||
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||
|
||||
await useAuthStore.getState().loadUser();
|
||||
const state = useAuthStore.getState();
|
||||
|
||||
expect(state.user).toBeNull();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-AUTH-006: logout', () => {
|
||||
it('calls disconnect() and clears user state', () => {
|
||||
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||
|
||||
useAuthStore.getState().logout();
|
||||
const state = useAuthStore.getState();
|
||||
|
||||
expect(disconnect).toHaveBeenCalledOnce();
|
||||
expect(state.user).toBeNull();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-AUTH-007: Register success', () => {
|
||||
it('sets user and authenticates', async () => {
|
||||
const user = buildUser();
|
||||
server.use(
|
||||
http.post('/api/auth/register', () =>
|
||||
HttpResponse.json({ user, token: 'tok' })
|
||||
)
|
||||
);
|
||||
|
||||
await useAuthStore.getState().register(user.username, user.email, 'password');
|
||||
const state = useAuthStore.getState();
|
||||
|
||||
expect(state.user).toEqual(user);
|
||||
expect(state.isAuthenticated).toBe(true);
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-AUTH-008: authSequence guard', () => {
|
||||
it('stale loadUser does not overwrite fresh login state', async () => {
|
||||
let resolveStale!: (v: Response) => void;
|
||||
const stalePromise = new Promise<Response>((res) => { resolveStale = res; });
|
||||
|
||||
// First call to /auth/me will hang until we resolve it manually
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get('/api/auth/me', async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
// Stale request — wait
|
||||
await stalePromise;
|
||||
return HttpResponse.json({ user: buildUser({ username: 'stale' }) });
|
||||
}
|
||||
// Should not be called a second time in this test
|
||||
return HttpResponse.json({ user: buildUser({ username: 'fresh' }) });
|
||||
})
|
||||
);
|
||||
|
||||
// Start loadUser but don't await yet
|
||||
const staleLoad = useAuthStore.getState().loadUser();
|
||||
|
||||
// Meanwhile, perform a login (bumps authSequence)
|
||||
const freshUser = buildUser({ username: 'freshlogin' });
|
||||
server.use(
|
||||
http.post('/api/auth/login', () =>
|
||||
HttpResponse.json({ user: freshUser, token: 'tok' })
|
||||
)
|
||||
);
|
||||
await useAuthStore.getState().login(freshUser.email, 'password');
|
||||
|
||||
// Now resolve the stale loadUser response
|
||||
resolveStale(new Response());
|
||||
await staleLoad;
|
||||
|
||||
// The fresh login state must be preserved
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.user?.username).toBe('freshlogin');
|
||||
expect(state.isAuthenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-AUTH-009: MFA-required state handling', () => {
|
||||
it('returns mfa_required flag and does not set user as authenticated', async () => {
|
||||
server.use(
|
||||
http.post('/api/auth/login', () =>
|
||||
HttpResponse.json({ mfa_required: true, mfa_token: 'mfa-tok-123' })
|
||||
)
|
||||
);
|
||||
|
||||
const result = await useAuthStore.getState().login('user@example.com', 'password');
|
||||
|
||||
expect(result).toMatchObject({ mfa_required: true, mfa_token: 'mfa-tok-123' });
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
expect(state.user).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { useInAppNotificationStore } from '../../../src/store/inAppNotificationStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
|
||||
// Raw notification factory matching the server shape (is_read as 0/1, params as strings)
|
||||
function buildRawNotif(overrides: Record<string, unknown> = {}) {
|
||||
const id = Math.floor(Math.random() * 100000);
|
||||
return {
|
||||
id,
|
||||
type: 'simple',
|
||||
scope: 'trip',
|
||||
target: 1,
|
||||
sender_id: 2,
|
||||
sender_username: 'alice',
|
||||
sender_avatar: null,
|
||||
recipient_id: 1,
|
||||
title_key: 'notif.title',
|
||||
title_params: '{}',
|
||||
text_key: 'notif.text',
|
||||
text_params: '{}',
|
||||
positive_text_key: null,
|
||||
negative_text_key: null,
|
||||
response: null,
|
||||
navigate_text_key: null,
|
||||
navigate_target: null,
|
||||
is_read: 0,
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('inAppNotificationStore', () => {
|
||||
describe('FE-NOTIF-001: fetchNotifications() loads first page', () => {
|
||||
it('populates notifications, total, and unreadCount', async () => {
|
||||
await useInAppNotificationStore.getState().fetchNotifications();
|
||||
const state = useInAppNotificationStore.getState();
|
||||
|
||||
expect(state.notifications.length).toBeGreaterThan(0);
|
||||
expect(state.total).toBeGreaterThan(0);
|
||||
expect(state.unreadCount).toBe(5);
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-NOTIF-002: Pagination — loading more appends to list', () => {
|
||||
it('appends additional notifications when fetchNotifications is called again', async () => {
|
||||
// First page
|
||||
await useInAppNotificationStore.getState().fetchNotifications(true);
|
||||
const firstPageCount = useInAppNotificationStore.getState().notifications.length;
|
||||
const total = useInAppNotificationStore.getState().total;
|
||||
|
||||
// Only test pagination if there are more items
|
||||
if (firstPageCount < total) {
|
||||
await useInAppNotificationStore.getState().fetchNotifications();
|
||||
const state = useInAppNotificationStore.getState();
|
||||
expect(state.notifications.length).toBeGreaterThan(firstPageCount);
|
||||
} else {
|
||||
// All notifications fit in one page
|
||||
expect(firstPageCount).toBe(total);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-NOTIF-003: markRead(id)', () => {
|
||||
it('updates is_read to true for the notification', async () => {
|
||||
// Seed with an unread notification
|
||||
const unread = buildRawNotif({ id: 42, is_read: 0 });
|
||||
useInAppNotificationStore.setState({
|
||||
notifications: [{ ...unread, title_params: {}, text_params: {}, is_read: false }] as never,
|
||||
unreadCount: 1,
|
||||
});
|
||||
|
||||
await useInAppNotificationStore.getState().markRead(42);
|
||||
const state = useInAppNotificationStore.getState();
|
||||
|
||||
const notif = state.notifications.find((n) => n.id === 42);
|
||||
expect(notif?.is_read).toBe(true);
|
||||
expect(state.unreadCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-NOTIF-004: handleNewNotification() prepends to list', () => {
|
||||
it('adds a new notification at the start of the list', () => {
|
||||
// Seed existing notifications
|
||||
useInAppNotificationStore.setState({
|
||||
notifications: [{ ...buildRawNotif({ id: 1 }), title_params: {}, text_params: {}, is_read: false }] as never,
|
||||
total: 1,
|
||||
unreadCount: 1,
|
||||
});
|
||||
|
||||
const newRaw = buildRawNotif({ id: 99 });
|
||||
useInAppNotificationStore.getState().handleNewNotification(newRaw as never);
|
||||
|
||||
const state = useInAppNotificationStore.getState();
|
||||
expect(state.notifications[0].id).toBe(99);
|
||||
expect(state.notifications.length).toBe(2);
|
||||
expect(state.total).toBe(2);
|
||||
expect(state.unreadCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-NOTIF-005: handleUpdatedNotification() updates existing notification', () => {
|
||||
it('replaces the notification in the list', () => {
|
||||
useInAppNotificationStore.setState({
|
||||
notifications: [{ ...buildRawNotif({ id: 7, is_read: 0 }), title_params: {}, text_params: {}, is_read: false }] as never,
|
||||
total: 1,
|
||||
unreadCount: 1,
|
||||
});
|
||||
|
||||
const updated = buildRawNotif({ id: 7, is_read: 1 });
|
||||
useInAppNotificationStore.getState().handleUpdatedNotification(updated as never);
|
||||
|
||||
const state = useInAppNotificationStore.getState();
|
||||
const notif = state.notifications.find((n) => n.id === 7);
|
||||
expect(notif?.is_read).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-NOTIF-006: Unread count is correct', () => {
|
||||
it('unreadCount matches the number of unread notifications', async () => {
|
||||
await useInAppNotificationStore.getState().fetchNotifications(true);
|
||||
const state = useInAppNotificationStore.getState();
|
||||
|
||||
// The mock returns 5 unread from the server
|
||||
expect(state.unreadCount).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { usePermissionsStore, useCanDo } from '../../../src/store/permissionsStore';
|
||||
import { useAuthStore } from '../../../src/store/authStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildUser, buildAdmin } from '../../helpers/factories';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('permissionsStore', () => {
|
||||
describe('FE-PERMS-001: setPermissions()', () => {
|
||||
it('stores the permission map', () => {
|
||||
const perms = { trip_create: 'everybody', file_upload: 'trip_member' } as const;
|
||||
usePermissionsStore.getState().setPermissions(perms);
|
||||
|
||||
expect(usePermissionsStore.getState().permissions).toEqual(perms);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PERMS-002: useCanDo() — basic allow/deny', () => {
|
||||
it('returns false when user is not authenticated', () => {
|
||||
usePermissionsStore.getState().setPermissions({ trip_create: 'everybody' });
|
||||
|
||||
const { result } = renderHook(() => useCanDo());
|
||||
expect(result.current('trip_create')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for "everybody" when user is authenticated', () => {
|
||||
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||
usePermissionsStore.getState().setPermissions({ trip_create: 'everybody' });
|
||||
|
||||
const { result } = renderHook(() => useCanDo());
|
||||
expect(result.current('trip_create')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when action has no configured permission (default allow)', () => {
|
||||
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||
usePermissionsStore.getState().setPermissions({});
|
||||
|
||||
const { result } = renderHook(() => useCanDo());
|
||||
expect(result.current('unconfigured_action')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin user', () => {
|
||||
it('can do anything regardless of configured permissions', () => {
|
||||
useAuthStore.setState({ user: buildAdmin(), isAuthenticated: true });
|
||||
usePermissionsStore.getState().setPermissions({ restricted_action: 'admin' });
|
||||
|
||||
const { result } = renderHook(() => useCanDo());
|
||||
expect(result.current('restricted_action')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Owner permissions', () => {
|
||||
it('trip_owner level: owner can act, member cannot', () => {
|
||||
const user = buildUser({ id: 42 });
|
||||
useAuthStore.setState({ user, isAuthenticated: true });
|
||||
usePermissionsStore.getState().setPermissions({ delete_trip: 'trip_owner' });
|
||||
|
||||
const { result } = renderHook(() => useCanDo());
|
||||
const trip = { owner_id: 42 }; // user is owner
|
||||
const otherTrip = { owner_id: 99 }; // user is not owner
|
||||
|
||||
expect(result.current('delete_trip', trip)).toBe(true);
|
||||
expect(result.current('delete_trip', otherTrip)).toBe(false);
|
||||
});
|
||||
|
||||
it('trip_owner level: is_owner flag grants access', () => {
|
||||
const user = buildUser({ id: 1 });
|
||||
useAuthStore.setState({ user, isAuthenticated: true });
|
||||
usePermissionsStore.getState().setPermissions({ delete_trip: 'trip_owner' });
|
||||
|
||||
const { result } = renderHook(() => useCanDo());
|
||||
expect(result.current('delete_trip', { is_owner: true })).toBe(true);
|
||||
expect(result.current('delete_trip', { is_owner: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Member permissions', () => {
|
||||
it('trip_member level: members and owners can act, unauthenticated trip context cannot', () => {
|
||||
const user = buildUser({ id: 1 });
|
||||
useAuthStore.setState({ user, isAuthenticated: true });
|
||||
usePermissionsStore.getState().setPermissions({ upload_file: 'trip_member' });
|
||||
|
||||
const { result } = renderHook(() => useCanDo());
|
||||
const asOwner = { owner_id: 1 }; // user is owner
|
||||
const asMember = { owner_id: 99 }; // user is member (trip context provided, not owner)
|
||||
const noTrip = null; // no trip context
|
||||
|
||||
expect(result.current('upload_file', asOwner)).toBe(true);
|
||||
expect(result.current('upload_file', asMember)).toBe(true);
|
||||
expect(result.current('upload_file', noTrip)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Nobody / admin-only level', () => {
|
||||
it('admin level: regular user is denied even as trip owner', () => {
|
||||
const user = buildUser({ id: 1 });
|
||||
useAuthStore.setState({ user, isAuthenticated: true });
|
||||
usePermissionsStore.getState().setPermissions({ admin_action: 'admin' });
|
||||
|
||||
const { result } = renderHook(() => useCanDo());
|
||||
expect(result.current('admin_action', { owner_id: 1 })).toBe(false);
|
||||
expect(result.current('admin_action')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { useSettingsStore } from '../../../src/store/settingsStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildSettings } from '../../helpers/factories';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('settingsStore', () => {
|
||||
describe('FE-SETTINGS-001: loadSettings()', () => {
|
||||
it('fetches settings and updates store', async () => {
|
||||
const settings = buildSettings({ default_currency: 'EUR', language: 'de' });
|
||||
server.use(
|
||||
http.get('/api/settings', () => HttpResponse.json({ settings }))
|
||||
);
|
||||
|
||||
await useSettingsStore.getState().loadSettings();
|
||||
const state = useSettingsStore.getState();
|
||||
|
||||
expect(state.settings.default_currency).toBe('EUR');
|
||||
expect(state.settings.language).toBe('de');
|
||||
expect(state.isLoaded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-SETTINGS-002: updateSetting() optimistic update', () => {
|
||||
it('immediately updates local state before API resolves', async () => {
|
||||
// The store's set() is called synchronously before the first await (settingsApi.set)
|
||||
// so state is visible without needing to await the full action.
|
||||
const promise = useSettingsStore.getState().updateSetting('default_currency', 'GBP');
|
||||
|
||||
// Check optimistic state — no await needed here
|
||||
expect(useSettingsStore.getState().settings.default_currency).toBe('GBP');
|
||||
|
||||
// Let the API call finish to avoid dangling promises
|
||||
await promise;
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-SETTINGS-003: updateSetting() reverts on API failure', () => {
|
||||
it('throws when API fails', async () => {
|
||||
server.use(
|
||||
http.put('/api/settings', () =>
|
||||
HttpResponse.json({ error: 'Server error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
|
||||
// The store optimistically sets, then throws — the revert is a throw
|
||||
await expect(
|
||||
useSettingsStore.getState().updateSetting('default_currency', 'GBP')
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-SETTINGS-004: Language change', () => {
|
||||
it('updates language field and localStorage', async () => {
|
||||
await useSettingsStore.getState().updateSetting('language', 'fr');
|
||||
|
||||
const state = useSettingsStore.getState();
|
||||
expect(state.settings.language).toBe('fr');
|
||||
expect(localStorage.getItem('app_language')).toBe('fr');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-SETTINGS-005: loadSettings failure', () => {
|
||||
it('sets isLoaded: true even on API failure (graceful)', async () => {
|
||||
server.use(
|
||||
http.get('/api/settings', () =>
|
||||
HttpResponse.json({ error: 'Server error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
|
||||
await useSettingsStore.getState().loadSettings();
|
||||
const state = useSettingsStore.getState();
|
||||
|
||||
expect(state.isLoaded).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { useVacayStore } from '../../../src/store/vacayStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('vacayStore', () => {
|
||||
describe('FE-VACAY-001: loadAll()', () => {
|
||||
it('fetches plan, years, entries, and stats, updates state', async () => {
|
||||
await useVacayStore.getState().loadAll();
|
||||
const state = useVacayStore.getState();
|
||||
|
||||
expect(state.plan).not.toBeNull();
|
||||
expect(state.plan?.id).toBe(1);
|
||||
expect(state.years).toEqual([2025, 2026]);
|
||||
expect(state.entries.length).toBeGreaterThan(0);
|
||||
expect(state.stats.length).toBeGreaterThan(0);
|
||||
expect(state.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-VACAY-002: toggleEntry()', () => {
|
||||
it('calls the toggle API then reloads entries and stats', async () => {
|
||||
// Seed selected year
|
||||
useVacayStore.setState({ selectedYear: 2025 });
|
||||
|
||||
let toggled = false;
|
||||
server.use(
|
||||
http.post('/api/addons/vacay/entries/toggle', () => {
|
||||
toggled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
|
||||
await useVacayStore.getState().toggleEntry('2025-06-20');
|
||||
|
||||
expect(toggled).toBe(true);
|
||||
// After toggle, entries are refreshed from MSW (2 entries)
|
||||
expect(useVacayStore.getState().entries.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-VACAY-003: loadHolidays() — holidays_enabled with calendars', () => {
|
||||
it('populates holidays map when plan has holiday calendars', async () => {
|
||||
// Set plan state with holidays_enabled and a simple (non-regional) calendar
|
||||
useVacayStore.setState({
|
||||
selectedYear: 2025,
|
||||
plan: {
|
||||
id: 1,
|
||||
holidays_enabled: true,
|
||||
holidays_region: null,
|
||||
holiday_calendars: [
|
||||
{ id: 1, plan_id: 1, region: 'DE', label: 'Germany', color: '#ef4444', sort_order: 0 },
|
||||
],
|
||||
block_weekends: true,
|
||||
carry_over_enabled: false,
|
||||
company_holidays_enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Override MSW to return non-regional holidays (no counties)
|
||||
server.use(
|
||||
http.get('/api/addons/vacay/holidays/:year/:country', () =>
|
||||
HttpResponse.json([
|
||||
{ date: '2025-12-25', name: 'Christmas', localName: 'Weihnachten', global: true, counties: null },
|
||||
{ date: '2025-01-01', name: 'New Year', localName: 'Neujahr', global: true, counties: null },
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
await useVacayStore.getState().loadHolidays(2025);
|
||||
const state = useVacayStore.getState();
|
||||
|
||||
expect(Object.keys(state.holidays).length).toBeGreaterThan(0);
|
||||
expect(state.holidays['2025-12-25']).toBeDefined();
|
||||
expect(state.holidays['2025-12-25'].name).toBe('Christmas');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-VACAY-003b: loadHolidays() — holidays not enabled', () => {
|
||||
it('sets holidays to empty map when holidays_enabled is false', async () => {
|
||||
useVacayStore.setState({
|
||||
selectedYear: 2025,
|
||||
plan: {
|
||||
id: 1,
|
||||
holidays_enabled: false,
|
||||
holidays_region: null,
|
||||
holiday_calendars: [],
|
||||
block_weekends: true,
|
||||
carry_over_enabled: false,
|
||||
company_holidays_enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
await useVacayStore.getState().loadHolidays(2025);
|
||||
expect(useVacayStore.getState().holidays).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-VACAY-004a: updatePlan()', () => {
|
||||
it('updates plan and reloads entries, stats, holidays', async () => {
|
||||
// Need existing plan for holiday check in loadHolidays
|
||||
useVacayStore.setState({
|
||||
selectedYear: 2025,
|
||||
plan: {
|
||||
id: 1,
|
||||
holidays_enabled: false,
|
||||
holidays_region: null,
|
||||
holiday_calendars: [],
|
||||
block_weekends: true,
|
||||
carry_over_enabled: false,
|
||||
company_holidays_enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
await useVacayStore.getState().updatePlan({ holidays_enabled: true });
|
||||
const state = useVacayStore.getState();
|
||||
|
||||
// The MSW handler for PUT /addons/vacay/plan returns holidays_enabled: true
|
||||
expect(state.plan?.holidays_enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-VACAY-004b: addYear()', () => {
|
||||
it('adds a year and the years list is updated', async () => {
|
||||
await useVacayStore.getState().addYear(2027);
|
||||
expect(useVacayStore.getState().years).toContain(2027);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-VACAY-004c: removeYear()', () => {
|
||||
it('removes a year and updates the years list', async () => {
|
||||
useVacayStore.setState({ years: [2025, 2026], selectedYear: 2026 });
|
||||
|
||||
await useVacayStore.getState().removeYear(2026);
|
||||
const state = useVacayStore.getState();
|
||||
|
||||
// MSW returns [2025] after delete
|
||||
expect(state.years).toEqual([2025]);
|
||||
// selectedYear should shift to the last remaining year
|
||||
expect(state.selectedYear).toBe(2025);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { useTripStore } from '../../src/store/tripStore';
|
||||
import { resetAllStores } from '../helpers/store';
|
||||
import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote } from '../helpers/factories';
|
||||
import { server } from '../helpers/msw/server';
|
||||
|
||||
vi.mock('../../src/api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
joinTrip: vi.fn(),
|
||||
leaveTrip: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('tripStore', () => {
|
||||
describe('loadTrip', () => {
|
||||
it('FE-TRIP-001: fires parallel API calls for trips, days, places, packing, todo, tags, categories', async () => {
|
||||
const calledUrls: string[] = [];
|
||||
server.use(
|
||||
http.get('/api/trips/:id', ({ params }) => {
|
||||
calledUrls.push(`/api/trips/${params.id}`);
|
||||
return HttpResponse.json({ trip: buildTrip({ id: Number(params.id) }) });
|
||||
}),
|
||||
http.get('/api/trips/:id/days', ({ params }) => {
|
||||
calledUrls.push(`/api/trips/${params.id}/days`);
|
||||
return HttpResponse.json({ days: [] });
|
||||
}),
|
||||
http.get('/api/trips/:id/places', ({ params }) => {
|
||||
calledUrls.push(`/api/trips/${params.id}/places`);
|
||||
return HttpResponse.json({ places: [] });
|
||||
}),
|
||||
http.get('/api/trips/:id/packing', ({ params }) => {
|
||||
calledUrls.push(`/api/trips/${params.id}/packing`);
|
||||
return HttpResponse.json({ items: [] });
|
||||
}),
|
||||
http.get('/api/trips/:id/todo', ({ params }) => {
|
||||
calledUrls.push(`/api/trips/${params.id}/todo`);
|
||||
return HttpResponse.json({ items: [] });
|
||||
}),
|
||||
http.get('/api/tags', () => {
|
||||
calledUrls.push('/api/tags');
|
||||
return HttpResponse.json({ tags: [] });
|
||||
}),
|
||||
http.get('/api/categories', () => {
|
||||
calledUrls.push('/api/categories');
|
||||
return HttpResponse.json({ categories: [] });
|
||||
}),
|
||||
);
|
||||
|
||||
await useTripStore.getState().loadTrip(1);
|
||||
|
||||
expect(calledUrls).toContain('/api/trips/1');
|
||||
expect(calledUrls).toContain('/api/trips/1/days');
|
||||
expect(calledUrls).toContain('/api/trips/1/places');
|
||||
expect(calledUrls).toContain('/api/trips/1/packing');
|
||||
expect(calledUrls).toContain('/api/trips/1/todo');
|
||||
expect(calledUrls).toContain('/api/tags');
|
||||
expect(calledUrls).toContain('/api/categories');
|
||||
});
|
||||
|
||||
it('FE-TRIP-002: after loadTrip, all store fields are populated', async () => {
|
||||
const trip = buildTrip({ id: 1 });
|
||||
const place = buildPlace({ trip_id: 1 });
|
||||
const packingItem = buildPackingItem({ trip_id: 1 });
|
||||
const todoItem = buildTodoItem({ trip_id: 1 });
|
||||
const tag = buildTag();
|
||||
const category = buildCategory();
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips/1', () => HttpResponse.json({ trip })),
|
||||
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
|
||||
http.get('/api/trips/1/places', () => HttpResponse.json({ places: [place] })),
|
||||
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [packingItem] })),
|
||||
http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [todoItem] })),
|
||||
http.get('/api/tags', () => HttpResponse.json({ tags: [tag] })),
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [category] })),
|
||||
);
|
||||
|
||||
await useTripStore.getState().loadTrip(1);
|
||||
const state = useTripStore.getState();
|
||||
|
||||
expect(state.trip).toEqual(trip);
|
||||
expect(state.places).toEqual([place]);
|
||||
expect(state.packingItems).toEqual([packingItem]);
|
||||
expect(state.todoItems).toEqual([todoItem]);
|
||||
expect(state.tags).toEqual([tag]);
|
||||
expect(state.categories).toEqual([category]);
|
||||
});
|
||||
|
||||
it('FE-TRIP-003: loadTrip extracts assignments map from days response', async () => {
|
||||
const assignment = buildAssignment({ day_id: 10, order_index: 0 });
|
||||
const day = buildDay({ id: 10, assignments: [assignment], notes_items: [] });
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips/1', () => HttpResponse.json({ trip: buildTrip({ id: 1 }) })),
|
||||
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [day] })),
|
||||
http.get('/api/trips/1/places', () => HttpResponse.json({ places: [] })),
|
||||
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/tags', () => HttpResponse.json({ tags: [] })),
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [] })),
|
||||
);
|
||||
|
||||
await useTripStore.getState().loadTrip(1);
|
||||
const { assignments } = useTripStore.getState();
|
||||
|
||||
expect(assignments['10']).toBeDefined();
|
||||
expect(assignments['10']).toEqual([assignment]);
|
||||
});
|
||||
|
||||
it('FE-TRIP-004: loadTrip extracts dayNotes map from days response', async () => {
|
||||
const note = buildDayNote({ day_id: 10 });
|
||||
const day = buildDay({ id: 10, assignments: [], notes_items: [note] });
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips/1', () => HttpResponse.json({ trip: buildTrip({ id: 1 }) })),
|
||||
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [day] })),
|
||||
http.get('/api/trips/1/places', () => HttpResponse.json({ places: [] })),
|
||||
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/tags', () => HttpResponse.json({ tags: [] })),
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [] })),
|
||||
);
|
||||
|
||||
await useTripStore.getState().loadTrip(1);
|
||||
const { dayNotes } = useTripStore.getState();
|
||||
|
||||
expect(dayNotes['10']).toBeDefined();
|
||||
expect(dayNotes['10']).toEqual([note]);
|
||||
});
|
||||
|
||||
it('FE-TRIP-005: loadTrip sets isLoading true during, false after', async () => {
|
||||
let wasLoadingDuringFetch = false;
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips/1', () => {
|
||||
wasLoadingDuringFetch = useTripStore.getState().isLoading;
|
||||
return HttpResponse.json({ trip: buildTrip({ id: 1 }) });
|
||||
}),
|
||||
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
|
||||
http.get('/api/trips/1/places', () => HttpResponse.json({ places: [] })),
|
||||
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/tags', () => HttpResponse.json({ tags: [] })),
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [] })),
|
||||
);
|
||||
|
||||
const promise = useTripStore.getState().loadTrip(1);
|
||||
expect(useTripStore.getState().isLoading).toBe(true);
|
||||
await promise;
|
||||
expect(wasLoadingDuringFetch).toBe(true);
|
||||
expect(useTripStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-TRIP-006: loadTrip on API failure sets error and isLoading: false', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1', () => HttpResponse.json({ message: 'Not found' }, { status: 404 })),
|
||||
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
|
||||
http.get('/api/trips/1/places', () => HttpResponse.json({ places: [] })),
|
||||
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/tags', () => HttpResponse.json({ tags: [] })),
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [] })),
|
||||
);
|
||||
|
||||
await expect(useTripStore.getState().loadTrip(1)).rejects.toThrow();
|
||||
|
||||
const state = useTripStore.getState();
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(state.error).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshDays', () => {
|
||||
it('FE-TRIP-007: refreshDays re-fetches days and rebuilds assignments/dayNotes maps', async () => {
|
||||
const assignment = buildAssignment({ day_id: 20, order_index: 0 });
|
||||
const note = buildDayNote({ day_id: 20 });
|
||||
const day = buildDay({ id: 20, assignments: [assignment], notes_items: [note] });
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [day] })),
|
||||
);
|
||||
|
||||
await useTripStore.getState().refreshDays(1);
|
||||
const state = useTripStore.getState();
|
||||
|
||||
expect(state.days).toHaveLength(1);
|
||||
expect(state.assignments['20']).toEqual([assignment]);
|
||||
expect(state.dayNotes['20']).toEqual([note]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTrip', () => {
|
||||
it('FE-TRIP-008: updateTrip persists and refreshes trip + days', async () => {
|
||||
const updatedTrip = buildTrip({ id: 1, name: 'Updated Trip' });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1', () => HttpResponse.json({ trip: updatedTrip })),
|
||||
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
|
||||
);
|
||||
|
||||
const result = await useTripStore.getState().updateTrip(1, { name: 'Updated Trip' });
|
||||
|
||||
expect(result).toEqual(updatedTrip);
|
||||
expect(useTripStore.getState().trip).toEqual(updatedTrip);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSelectedDay', () => {
|
||||
it('FE-TRIP-009: setSelectedDay updates selectedDayId', () => {
|
||||
useTripStore.getState().setSelectedDay(42);
|
||||
expect(useTripStore.getState().selectedDayId).toBe(42);
|
||||
|
||||
useTripStore.getState().setSelectedDay(null);
|
||||
expect(useTripStore.getState().selectedDayId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTag', () => {
|
||||
it('FE-TRIP-010: addTag creates tag and appends to tags', async () => {
|
||||
const existingTag = buildTag();
|
||||
useTripStore.setState({ tags: [existingTag] });
|
||||
|
||||
const newTagData = { name: 'New Tag', color: '#00ff00' };
|
||||
|
||||
const result = await useTripStore.getState().addTag(newTagData);
|
||||
|
||||
expect(result.name).toBe('New Tag');
|
||||
const tags = useTripStore.getState().tags;
|
||||
expect(tags).toHaveLength(2);
|
||||
expect(tags[tags.length - 1].name).toBe('New Tag');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addCategory', () => {
|
||||
it('FE-TRIP-011: addCategory creates category and appends to categories', async () => {
|
||||
const existingCategory = buildCategory();
|
||||
useTripStore.setState({ categories: [existingCategory] });
|
||||
|
||||
const newCategoryData = { name: 'New Category', icon: 'hotel' };
|
||||
|
||||
const result = await useTripStore.getState().addCategory(newCategoryData);
|
||||
|
||||
expect(result.name).toBe('New Category');
|
||||
const categories = useTripStore.getState().categories;
|
||||
expect(categories).toHaveLength(2);
|
||||
expect(categories[categories.length - 1].name).toBe('New Category');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../../src/utils/formatters';
|
||||
|
||||
describe('currencyDecimals', () => {
|
||||
it('returns 0 for zero-decimal currencies', () => {
|
||||
expect(currencyDecimals('JPY')).toBe(0);
|
||||
expect(currencyDecimals('KRW')).toBe(0);
|
||||
expect(currencyDecimals('jpy')).toBe(0); // case-insensitive
|
||||
});
|
||||
|
||||
it('returns 2 for standard currencies', () => {
|
||||
expect(currencyDecimals('EUR')).toBe(2);
|
||||
expect(currencyDecimals('USD')).toBe(2);
|
||||
expect(currencyDecimals('GBP')).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('returns null for null/undefined input', () => {
|
||||
expect(formatDate(null, 'en-US')).toBeNull();
|
||||
expect(formatDate(undefined, 'en-US')).toBeNull();
|
||||
});
|
||||
|
||||
it('formats a date string and returns a non-empty string', () => {
|
||||
const result = formatDate('2025-06-01', 'en-US');
|
||||
expect(result).not.toBeNull();
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('accepts an optional timeZone parameter without throwing', () => {
|
||||
const result = formatDate('2025-06-01', 'en-US', 'America/New_York');
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTime', () => {
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(formatTime(null, 'en-US', '24h')).toBe('');
|
||||
expect(formatTime(undefined, 'en-US', '24h')).toBe('');
|
||||
});
|
||||
|
||||
it('formats 24h time', () => {
|
||||
expect(formatTime('14:30', 'en-US', '24h')).toBe('14:30');
|
||||
expect(formatTime('09:05', 'en-US', '24h')).toBe('09:05');
|
||||
});
|
||||
|
||||
it('appends Uhr suffix for German locale in 24h mode', () => {
|
||||
expect(formatTime('14:30', 'de-DE', '24h')).toBe('14:30 Uhr');
|
||||
});
|
||||
|
||||
it('formats 12h time', () => {
|
||||
expect(formatTime('14:30', 'en-US', '12h')).toBe('2:30 PM');
|
||||
expect(formatTime('00:00', 'en-US', '12h')).toBe('12:00 AM');
|
||||
expect(formatTime('12:00', 'en-US', '12h')).toBe('12:00 PM');
|
||||
expect(formatTime('01:00', 'en-US', '12h')).toBe('1:00 AM');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dayTotalCost', () => {
|
||||
it('returns null when there are no assignments', () => {
|
||||
expect(dayTotalCost(1, {}, 'EUR')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no places have prices', () => {
|
||||
const assignments = {
|
||||
'1': [
|
||||
{ id: 1, day_id: 1, order_index: 0, notes: null, place: { id: 1, trip_id: 1, name: 'P', lat: null, lng: null, description: null, address: null, category_id: null, icon: null, price: null, image_url: null, google_place_id: null, osm_id: null, route_geometry: null, place_time: null, end_time: null, created_at: '' } },
|
||||
],
|
||||
};
|
||||
expect(dayTotalCost(1, assignments, 'EUR')).toBeNull();
|
||||
});
|
||||
|
||||
it('sums prices across assignments', () => {
|
||||
const assignments = {
|
||||
'1': [
|
||||
{ id: 1, day_id: 1, order_index: 0, notes: null, place: { id: 1, trip_id: 1, name: 'A', lat: null, lng: null, description: null, address: null, category_id: null, icon: null, price: '20', image_url: null, google_place_id: null, osm_id: null, route_geometry: null, place_time: null, end_time: null, created_at: '' } },
|
||||
{ id: 2, day_id: 1, order_index: 1, notes: null, place: { id: 2, trip_id: 1, name: 'B', lat: null, lng: null, description: null, address: null, category_id: null, icon: null, price: '30', image_url: null, google_place_id: null, osm_id: null, route_geometry: null, place_time: null, end_time: null, created_at: '' } },
|
||||
],
|
||||
};
|
||||
expect(dayTotalCost(1, assignments, 'EUR')).toBe('50 EUR');
|
||||
});
|
||||
|
||||
it('ignores non-numeric price strings', () => {
|
||||
const assignments = {
|
||||
'1': [
|
||||
{ id: 1, day_id: 1, order_index: 0, notes: null, place: { id: 1, trip_id: 1, name: 'A', lat: null, lng: null, description: null, address: null, category_id: null, icon: null, price: 'free', image_url: null, google_place_id: null, osm_id: null, route_geometry: null, place_time: null, end_time: null, created_at: '' } },
|
||||
],
|
||||
};
|
||||
expect(dayTotalCost(1, assignments, 'EUR')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the dayId key to look up assignments', () => {
|
||||
const assignments = {
|
||||
'2': [
|
||||
{ id: 3, day_id: 2, order_index: 0, notes: null, place: { id: 3, trip_id: 1, name: 'C', lat: null, lng: null, description: null, address: null, category_id: null, icon: null, price: '10', image_url: null, google_place_id: null, osm_id: null, route_geometry: null, place_time: null, end_time: null, created_at: '' } },
|
||||
],
|
||||
};
|
||||
expect(dayTotalCost(1, assignments, 'USD')).toBeNull();
|
||||
expect(dayTotalCost(2, assignments, 'USD')).toBe('10 USD');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { swapItems } from '../../../src/utils/reorder';
|
||||
|
||||
// FE-UTIL-020 onwards
|
||||
|
||||
const items = [
|
||||
{ id: 10 },
|
||||
{ id: 20 },
|
||||
{ id: 30 },
|
||||
{ id: 40 },
|
||||
];
|
||||
|
||||
describe('swapItems', () => {
|
||||
it('FE-UTIL-020: swaps item up with its predecessor', () => {
|
||||
const result = swapItems(items, 1, 'up');
|
||||
expect(result).toEqual([20, 10, 30, 40]);
|
||||
});
|
||||
|
||||
it('FE-UTIL-021: swaps item down with its successor', () => {
|
||||
const result = swapItems(items, 1, 'down');
|
||||
expect(result).toEqual([10, 30, 20, 40]);
|
||||
});
|
||||
|
||||
it('FE-UTIL-022: returns null when moving first item up (out of bounds)', () => {
|
||||
expect(swapItems(items, 0, 'up')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-UTIL-023: returns null when moving last item down (out of bounds)', () => {
|
||||
expect(swapItems(items, items.length - 1, 'down')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-UTIL-024: swaps first and second items when moving index 1 up', () => {
|
||||
const result = swapItems(items, 1, 'up');
|
||||
expect(result![0]).toBe(20);
|
||||
expect(result![1]).toBe(10);
|
||||
});
|
||||
|
||||
it('FE-UTIL-025: returns an array of IDs (not objects)', () => {
|
||||
const result = swapItems(items, 0, 'down');
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(typeof result![0]).toBe('number');
|
||||
});
|
||||
|
||||
it('FE-UTIL-026: does not mutate the original array', () => {
|
||||
const original = [{ id: 1 }, { id: 2 }, { id: 3 }];
|
||||
const snapshot = original.map((o) => o.id);
|
||||
swapItems(original, 0, 'down');
|
||||
expect(original.map((o) => o.id)).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('FE-UTIL-027: returns null for a single-element array moving down', () => {
|
||||
expect(swapItems([{ id: 5 }], 0, 'down')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-UTIL-028: returns null for a single-element array moving up', () => {
|
||||
expect(swapItems([{ id: 5 }], 0, 'up')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-UTIL-029: swaps last two items when moving second-to-last down', () => {
|
||||
const result = swapItems(items, items.length - 2, 'down');
|
||||
expect(result).toEqual([10, 20, 40, 30]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user