Files
TREK/client/tests/unit/remoteEventHandler/budget.test.ts
T

202 lines
8.0 KiB
TypeScript

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 { BudgetItemMember } from '../../../src/types';
beforeEach(() => {
resetAllStores();
});
describe('remoteEventHandler > budget', () => {
const member1: BudgetItemMember = { user_id: 5, paid: 0, username: 'eve' };
const member2: BudgetItemMember = { user_id: 6, paid: 1, username: 'frank' };
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', total_price: 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?.total_price).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: BudgetItemMember[] = [{ user_id: 7, paid: 1, username: 'grace' }, { user_id: 8, paid: 0, username: 'heidi' }];
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 (member2 keeps its seeded paid value)
const item2 = budgetItems.find(i => i.id === 2);
expect(item2?.members?.[0].paid).toBe(1);
});
it('FE-WSEVT-BUDGET-007: budget:members-updated for an unknown item leaves every item untouched', () => {
seedData();
useTripStore.getState().handleRemoteEvent({
type: 'budget:members-updated',
itemId: 4242,
members: [{ user_id: 9, paid: 0, username: 'ivan' }],
persons: 9,
});
const { budgetItems } = useTripStore.getState();
expect(budgetItems.map(i => i.persons)).toEqual([1, 2]);
expect(budgetItems[0].members).toEqual([{ ...member1 }]);
});
it('FE-WSEVT-BUDGET-008: budget:member-paid-updated only touches the addressed member', () => {
useTripStore.setState({
budgetItems: [
buildBudgetItem({ id: 1, members: [{ ...member1 }, { ...member2 }] }),
],
});
useTripStore.getState().handleRemoteEvent({
type: 'budget:member-paid-updated',
itemId: 1,
userId: 5,
paid: 1,
});
const members = useTripStore.getState().budgetItems[0].members ?? [];
expect(members.find(m => m.user_id === 5)?.paid).toBe(1);
expect(members.find(m => m.user_id === 6)?.paid).toBe(1); // seeded value, untouched
});
it('FE-WSEVT-BUDGET-009: budget:member-paid-updated on an item without members does not throw', () => {
useTripStore.setState({
budgetItems: [buildBudgetItem({ id: 1, members: undefined })],
});
useTripStore.getState().handleRemoteEvent({
type: 'budget:member-paid-updated',
itemId: 1,
userId: 5,
paid: 1,
});
expect(useTripStore.getState().budgetItems[0].members).toEqual([]);
});
describe('budget:reordered', () => {
it('FE-WSEVT-BUDGET-010: orderedIds rewrites sort_order and keeps unlisted items at the end', () => {
useTripStore.setState({
budgetItems: [
buildBudgetItem({ id: 1, sort_order: 0 }),
buildBudgetItem({ id: 2, sort_order: 1 }),
buildBudgetItem({ id: 3, sort_order: 2 }),
],
});
useTripStore.getState().handleRemoteEvent({ type: 'budget:reordered', orderedIds: [3, 1] });
const { budgetItems } = useTripStore.getState();
expect(budgetItems.map(i => i.id)).toEqual([3, 1, 2]);
// Only the listed ids get a fresh sort_order; the unlisted item keeps its own.
expect(budgetItems.map(i => i.sort_order)).toEqual([0, 1, 1]);
});
it('FE-WSEVT-BUDGET-011: an orderedIds entry for an unknown item is dropped', () => {
useTripStore.setState({
budgetItems: [buildBudgetItem({ id: 1 }), buildBudgetItem({ id: 2 })],
});
useTripStore.getState().handleRemoteEvent({ type: 'budget:reordered', orderedIds: [2, 999, 1] });
expect(useTripStore.getState().budgetItems.map(i => i.id)).toEqual([2, 1]);
});
it('FE-WSEVT-BUDGET-012: orderedCategories groups items and appends unlisted categories', () => {
useTripStore.setState({
budgetItems: [
buildBudgetItem({ id: 1, category: 'Food' }),
buildBudgetItem({ id: 2, category: 'Transport' }),
buildBudgetItem({ id: 3, category: 'Food' }),
buildBudgetItem({ id: 4, category: 'Lodging' }),
],
});
useTripStore.getState().handleRemoteEvent({
type: 'budget:reordered',
orderedCategories: ['Transport', 'Food', 'Souvenirs'],
});
// Transport, then both Food rows in their original order, then the
// category the server did not mention.
expect(useTripStore.getState().budgetItems.map(i => i.id)).toEqual([2, 1, 3, 4]);
});
it('FE-WSEVT-BUDGET-013: an item without a category is grouped under "Other"', () => {
useTripStore.setState({
budgetItems: [
buildBudgetItem({ id: 1, category: null }),
buildBudgetItem({ id: 2, category: 'Food' }),
],
});
useTripStore.getState().handleRemoteEvent({
type: 'budget:reordered',
orderedCategories: ['Other', 'Food'],
});
expect(useTripStore.getState().budgetItems.map(i => i.id)).toEqual([1, 2]);
});
it('FE-WSEVT-BUDGET-014: a payload with neither key is a no-op', () => {
useTripStore.setState({
budgetItems: [buildBudgetItem({ id: 1 }), buildBudgetItem({ id: 2 })],
});
useTripStore.getState().handleRemoteEvent({ type: 'budget:reordered' });
expect(useTripStore.getState().budgetItems.map(i => i.id)).toEqual([1, 2]);
});
});
});