Files
TREK/client/src/components/Packing/usePackingListPanel.test.tsx
T
Maurice dc60f39554 feat(ui): Costs bar like the other tabs, desktop packing list in the phone's look
The Costs tab opens with the same bar as Transports, Bookings, Lists and
Files: the tab's name, the trip's dates and days in one pill, the
travelers, and Settle up and Add expense on the right.

The desktop packing list takes the phone's look without losing anything
it shows. Category cards get a tinted head band, rows get hairlines and
filled checkboxes, owner, quantity and weight read as small badges, and
the bin and a menu with Move to List, Sharing, Rename and Delete replace
the row of icons. What a row does not use stays dimmed until hover or
focus, so nothing leaves a gap. A click on the name renames, ticked
items too. Add item opens a composer shaped like the next row, Add list
moved into the Lists bar, Add list and Save as template ask for the name
in a dialog, and Remove checked sits beside the progress bar. The Bags
panel becomes a card, and the item menu, the bag picker, the list menu
and the bag member picker share one popover look and close on a click
beside them.

Help center pictures for Costs and Lists are re-rendered, the Lists
guide texts follow the new controls in all 27 languages, and the wiki
pages are updated. The phone screens are unchanged.
2026-09-25 12:57:15 +02:00

873 lines
36 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// FE-W5HOOK-001 to FE-W5HOOK-058
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { ChangeEvent, ReactNode } from 'react'
import { http, HttpResponse } from 'msw'
import { renderHook, act, waitFor } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext'
import { server } from '../../../tests/helpers/msw/server'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser, buildAdmin, buildTrip, buildPackingItem } from '../../../tests/helpers/factories'
import { useAuthStore } from '../../store/authStore'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { usePermissionsStore } from '../../store/permissionsStore'
import { usePackingList, type PackingListPanelProps } from './usePackingListPanel'
import type { PackingItem } from '../../types'
const wrapper = ({ children }: { children: ReactNode }) => <TranslationProvider>{children}</TranslationProvider>
const toastSpy = vi.fn((_message: string, _type?: string, _duration?: number) => 0)
function renderPanel(props: Partial<PackingListPanelProps> = {}) {
return renderHook((p: PackingListPanelProps) => usePackingList(p), {
wrapper,
initialProps: { tripId: 1, items: [], ...props },
})
}
/** Waits for the mount effects (members, assignees, templates) to settle. */
async function settled() {
await act(async () => { await Promise.resolve() })
}
beforeEach(() => {
resetAllStores()
toastSpy.mockClear()
window.__addToast = toastSpy
server.use(
http.get('/api/trips/:id/members', () =>
HttpResponse.json({ owner: { id: 1, username: 'owner', avatar_url: null }, members: [] })),
http.get('/api/trips/:id/packing/category-assignees', () => HttpResponse.json({ assignees: {} })),
http.get('/api/addons', () => HttpResponse.json({ bagTracking: false, addons: [] })),
http.get('/api/trips/:id/packing/templates', () => HttpResponse.json({ templates: [] })),
http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] })),
)
seedStore(useAuthStore, { user: buildUser({ id: 1 }), isAuthenticated: true })
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) })
})
afterEach(() => {
delete window.__addToast
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('usePackingList — members & assignees', () => {
it('FE-W5HOOK-001: merges the owner and the members into one member list', async () => {
server.use(
http.get('/api/trips/:id/members', () =>
HttpResponse.json({
owner: { id: 1, username: 'owner', avatar_url: 'a.png' },
members: [{ id: 2, username: 'alice', avatar_url: null, is_guest: 1 }],
})),
)
const { result } = renderPanel()
await waitFor(() => expect(result.current.tripMembers).toHaveLength(2))
expect(result.current.tripMembers[0]).toEqual({ id: 1, username: 'owner', avatar: 'a.png', is_guest: false })
expect(result.current.tripMembers[1]).toEqual({ id: 2, username: 'alice', avatar: null, is_guest: true })
})
it('FE-W5HOOK-002: a response without owner or members yields no members', async () => {
server.use(http.get('/api/trips/:id/members', () => HttpResponse.json({})))
const { result } = renderPanel()
await settled()
expect(result.current.tripMembers).toEqual([])
})
it('FE-W5HOOK-003: a failing members request leaves the list empty', async () => {
server.use(http.get('/api/trips/:id/members', () => new HttpResponse(null, { status: 500 })))
const { result } = renderPanel()
await settled()
expect(result.current.tripMembers).toEqual([])
})
it('FE-W5HOOK-004: an assignee response without a map falls back to an empty record', async () => {
server.use(http.get('/api/trips/:id/packing/category-assignees', () => HttpResponse.json({})))
const { result } = renderPanel()
await settled()
expect(result.current.categoryAssignees).toEqual({})
})
it('FE-W5HOOK-005: setting assignees stores the returned list for that category', async () => {
server.use(
http.get('/api/trips/:id/packing/category-assignees', () => HttpResponse.json({ assignees: { Docs: [] } })),
http.put('/api/trips/1/packing/category-assignees/Gear', () =>
HttpResponse.json({ assignees: [{ user_id: 2, username: 'alice' }] })),
)
const { result } = renderPanel()
await waitFor(() => expect(result.current.categoryAssignees.Docs).toEqual([]))
await act(async () => { await result.current.handleSetAssignees('Gear', [2]) })
expect(result.current.categoryAssignees.Gear).toEqual([{ user_id: 2, username: 'alice' }])
// The existing categories survive the merge.
expect(result.current.categoryAssignees.Docs).toEqual([])
})
it('FE-W5HOOK-006: an assignee response without a list stores an empty array', async () => {
server.use(
http.get('/api/trips/:id/packing/category-assignees', () => HttpResponse.json({ assignees: { Docs: [] } })),
http.put('/api/trips/1/packing/category-assignees/Gear', () => HttpResponse.json({})),
)
const { result } = renderPanel()
await waitFor(() => expect(result.current.categoryAssignees.Docs).toEqual([]))
await act(async () => { await result.current.handleSetAssignees('Gear', [2]) })
expect(result.current.categoryAssignees.Gear).toEqual([])
})
it('FE-W5HOOK-007: a failing assignee update surfaces a save error', async () => {
server.use(http.put('/api/trips/1/packing/category-assignees/Gear', () => new HttpResponse(null, { status: 500 })))
const { result } = renderPanel()
await act(async () => { await result.current.handleSetAssignees('Gear', [2]) })
expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined)
})
})
describe('usePackingList — grouping, filtering and progress', () => {
const items = [
buildPackingItem({ id: 1, name: 'Tent', category: 'Gear', checked: 1 }),
buildPackingItem({ id: 2, name: 'Rope', category: 'Gear', checked: 0 }),
buildPackingItem({ id: 3, name: 'Soap', category: null, checked: 0 }),
buildPackingItem({ id: 4, name: 'Diary', category: 'Gear', is_private: 1 } as Partial<PackingItem>),
]
it('FE-W5HOOK-008: the common view hides private items and buckets by category', async () => {
const { result } = renderPanel({ items })
await settled()
expect(result.current.allCategories).toEqual(['Gear', 'Other'])
expect(result.current.gruppiert.Gear.map(i => i.name)).toEqual(['Tent', 'Rope'])
expect(result.current.gruppiert.Other.map(i => i.name)).toEqual(['Soap'])
})
it('FE-W5HOOK-009: the personal view shows only private items', async () => {
const { result } = renderPanel({ items })
await settled()
act(() => result.current.setView('personal'))
expect(result.current.view).toBe('personal')
expect(result.current.gruppiert.Gear.map(i => i.name)).toEqual(['Diary'])
})
it('FE-W5HOOK-010: the "offen" filter keeps only unchecked items', async () => {
const { result } = renderPanel({ items })
await settled()
act(() => result.current.setFilter('offen'))
expect(result.current.gruppiert.Gear.map(i => i.name)).toEqual(['Rope'])
})
it('FE-W5HOOK-011: the "erledigt" filter keeps only checked items', async () => {
const { result } = renderPanel({ items })
await settled()
act(() => result.current.setFilter('erledigt'))
expect(result.current.gruppiert.Gear.map(i => i.name)).toEqual(['Tent'])
expect(result.current.gruppiert.Other).toBeUndefined()
})
it('FE-W5HOOK-012: progress is the checked share of the active view', async () => {
const { result } = renderPanel({ items })
await settled()
expect(result.current.abgehakt).toBe(1)
expect(result.current.fortschritt).toBe(33)
})
it('FE-W5HOOK-013: an empty view reports zero progress instead of NaN', async () => {
const { result } = renderPanel()
await settled()
expect(result.current.fortschritt).toBe(0)
})
it('FE-W5HOOK-014: a controlled view prop drives the split and reports changes upwards', async () => {
const onViewChange = vi.fn()
const { result } = renderPanel({ items, view: 'personal', onViewChange })
await settled()
expect(result.current.gruppiert.Gear.map(i => i.name)).toEqual(['Diary'])
act(() => result.current.setView('common'))
expect(onViewChange).toHaveBeenCalledWith('common')
// Controlled: the hook does not flip its own state.
expect(result.current.view).toBe('personal')
})
})
describe('usePackingList — item and category CRUD', () => {
it('FE-W5HOOK-015: adding to a category reuses its placeholder row', async () => {
const placeholder = buildPackingItem({ id: 9, name: '...', category: 'Gear' })
seedStore(useTripStore, { packingItems: [placeholder] })
let put: Record<string, unknown> | null = null
let posted = false
server.use(
http.put('/api/trips/1/packing/9', async ({ request }) => {
put = (await request.json()) as Record<string, unknown>
return HttpResponse.json({ item: placeholder })
}),
http.post('/api/trips/1/packing', () => { posted = true; return HttpResponse.json({ item: placeholder }) }),
)
const { result } = renderPanel({ items: [placeholder] })
await act(async () => { await result.current.handleAddItemToCategory('Gear', 'Tent') })
expect(put).toMatchObject({ name: 'Tent' })
expect(posted).toBe(false)
})
it('FE-W5HOOK-016: a new item in the personal view is created as personal', async () => {
let body: Record<string, unknown> | null = null
server.use(
http.post('/api/trips/1/packing', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>
return HttpResponse.json({ item: buildPackingItem({ id: 3 }) })
}),
)
const { result } = renderPanel({ view: 'personal', onViewChange: () => {} })
await act(async () => { await result.current.handleAddItemToCategory('Gear', 'Tent') })
expect(body).toMatchObject({ name: 'Tent', category: 'Gear', visibility: 'personal' })
})
it('FE-W5HOOK-017: a failing add surfaces an add error', async () => {
server.use(http.post('/api/trips/1/packing', () => new HttpResponse(null, { status: 500 })))
const { result } = renderPanel()
await act(async () => { await result.current.handleAddItemToCategory('Gear', 'Tent') })
expect(toastSpy).toHaveBeenCalledWith('Failed to add', 'error', undefined)
})
it('FE-W5HOOK-018: deleting the last item of a category unchecks it and converts it to a placeholder', async () => {
const item = buildPackingItem({ id: 11, name: 'Tent', category: 'Gear', checked: 1 })
const bodies: Record<string, unknown>[] = []
let deleted = false
server.use(
http.put('/api/trips/1/packing/11', async ({ request }) => {
bodies.push((await request.json()) as Record<string, unknown>)
return HttpResponse.json({ item })
}),
http.delete('/api/trips/1/packing/11', () => { deleted = true; return HttpResponse.json({ success: true }) }),
)
const { result } = renderPanel({ items: [item] })
await act(async () => { await result.current.handleDeleteItem(item) })
expect(bodies[0]).toMatchObject({ checked: false })
expect(bodies[1]).toMatchObject({ name: '...', weight_grams: null, bag_id: null, quantity: 1 })
expect(deleted).toBe(false)
})
it('FE-W5HOOK-018a: the last item of a category is converted without an extra uncheck call', async () => {
const item = buildPackingItem({ id: 14, name: 'Tent', category: 'Gear', checked: 0 })
const bodies: Record<string, unknown>[] = []
server.use(
http.put('/api/trips/1/packing/14', async ({ request }) => {
bodies.push((await request.json()) as Record<string, unknown>)
return HttpResponse.json({ item })
}),
)
const { result } = renderPanel({ items: [item] })
await act(async () => { await result.current.handleDeleteItem(item) })
expect(bodies).toHaveLength(1)
expect(bodies[0]).toMatchObject({ name: '...' })
})
it('FE-W5HOOK-018b: an item with siblings in its category is deleted outright', async () => {
const item = buildPackingItem({ id: 15, name: 'Tent', category: 'Gear' })
const sibling = buildPackingItem({ id: 16, name: 'Rope', category: 'Gear' })
let deleted = false
let converted = false
server.use(
http.delete('/api/trips/1/packing/15', () => { deleted = true; return HttpResponse.json({ success: true }) }),
http.put('/api/trips/1/packing/15', () => { converted = true; return HttpResponse.json({ item }) }),
)
const { result } = renderPanel({ items: [item, sibling] })
await act(async () => { await result.current.handleDeleteItem(item) })
expect(deleted).toBe(true)
expect(converted).toBe(false)
})
it('FE-W5HOOK-019: an uncategorized item is deleted outright', async () => {
const item = buildPackingItem({ id: 12, name: 'Soap', category: null })
let deleted = false
server.use(http.delete('/api/trips/1/packing/12', () => { deleted = true; return HttpResponse.json({ success: true }) }))
const { result } = renderPanel({ items: [item] })
await act(async () => { await result.current.handleDeleteItem(item) })
expect(deleted).toBe(true)
})
it('FE-W5HOOK-020: a failing delete surfaces a delete error', async () => {
const item = buildPackingItem({ id: 13, name: 'Soap', category: null })
server.use(http.delete('/api/trips/1/packing/13', () => new HttpResponse(null, { status: 500 })))
const { result } = renderPanel({ items: [item] })
await act(async () => { await result.current.handleDeleteItem(item) })
expect(toastSpy).toHaveBeenCalledWith('Failed to delete', 'error', undefined)
})
it('FE-W5HOOK-021: adding a category without a name does nothing', async () => {
let posted = false
server.use(http.post('/api/trips/1/packing', () => { posted = true; return HttpResponse.json({ item: buildPackingItem() }) }))
const { result } = renderPanel()
act(() => result.current.setNewCatName(' '))
await act(async () => { await result.current.handleAddNewCategory() })
expect(posted).toBe(false)
})
it('FE-W5HOOK-022: a duplicate category name gets a zero-width suffix so it stays distinct', async () => {
let body: Record<string, unknown> | null = null
server.use(
http.post('/api/trips/1/packing', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>
return HttpResponse.json({ item: buildPackingItem() })
}),
)
const { result } = renderPanel({ items: [buildPackingItem({ id: 20, category: 'Gear' })] })
await settled()
act(() => result.current.setNewCatName('Gear'))
await act(async () => { await result.current.handleAddNewCategory() })
expect(body).toMatchObject({ name: '...', category: 'Gear​', visibility: 'common' })
expect(result.current.newCatName).toBe('')
expect(result.current.addingCategory).toBe(false)
})
it('FE-W5HOOK-022a: a category added from the personal view is created as personal', async () => {
let body: Record<string, unknown> | null = null
server.use(
http.post('/api/trips/1/packing', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>
return HttpResponse.json({ item: buildPackingItem() })
}),
)
const { result } = renderPanel({ view: 'personal', onViewChange: () => {} })
act(() => result.current.setNewCatName('Gear'))
await act(async () => { await result.current.handleAddNewCategory() })
expect(body).toMatchObject({ category: 'Gear', visibility: 'personal' })
})
it('FE-W5HOOK-023: a failing category add surfaces an add error', async () => {
server.use(http.post('/api/trips/1/packing', () => new HttpResponse(null, { status: 500 })))
const { result } = renderPanel()
act(() => result.current.setNewCatName('Gear'))
await act(async () => { await result.current.handleAddNewCategory() })
expect(toastSpy).toHaveBeenCalledWith('Failed to add', 'error', undefined)
})
it('FE-W5HOOK-024: renaming the default category moves every uncategorized item', async () => {
const items = [
buildPackingItem({ id: 31, category: null }),
buildPackingItem({ id: 32, category: 'Gear' }),
]
const touched: number[] = []
server.use(
http.put('/api/trips/1/packing/:itemId', ({ params }) => {
touched.push(Number(params.itemId))
return HttpResponse.json({ item: items[0] })
}),
)
const { result } = renderPanel({ items })
await act(async () => { await result.current.handleRenameCategory('Other', 'Misc') })
expect(touched).toEqual([31])
})
it('FE-W5HOOK-025: a partly failing category delete surfaces a delete error', async () => {
const items = [buildPackingItem({ id: 41 }), buildPackingItem({ id: 42 })]
server.use(
http.delete('/api/trips/1/packing/41', () => HttpResponse.json({ success: true })),
http.delete('/api/trips/1/packing/42', () => new HttpResponse(null, { status: 500 })),
)
const { result } = renderPanel({ items })
await act(async () => { await result.current.handleDeleteCategory(items) })
expect(toastSpy).toHaveBeenCalledWith('Failed to delete', 'error', undefined)
})
it('FE-W5HOOK-025a: a clean category delete stays quiet', async () => {
const items = [buildPackingItem({ id: 43 }), buildPackingItem({ id: 44 })]
const deleted: number[] = []
server.use(
http.delete('/api/trips/1/packing/:itemId', ({ params }) => {
deleted.push(Number(params.itemId))
return HttpResponse.json({ success: true })
}),
)
const { result } = renderPanel({ items })
await act(async () => { await result.current.handleDeleteCategory(items) })
expect(deleted).toEqual([43, 44])
expect(toastSpy).not.toHaveBeenCalled()
})
it('FE-W5HOOK-026: declining the clear-checked confirmation deletes nothing', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false)
let deleted = false
server.use(http.delete('/api/trips/1/packing/:itemId', () => { deleted = true; return HttpResponse.json({ success: true }) }))
const { result } = renderPanel({ items: [buildPackingItem({ id: 51, checked: 1 })] })
await act(async () => { await result.current.handleClearChecked() })
expect(deleted).toBe(false)
})
it('FE-W5HOOK-027: a failing clear-checked delete surfaces a delete error', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
server.use(http.delete('/api/trips/1/packing/52', () => new HttpResponse(null, { status: 500 })))
const { result } = renderPanel({ items: [buildPackingItem({ id: 52, checked: 1 })] })
await act(async () => { await result.current.handleClearChecked() })
expect(toastSpy).toHaveBeenCalledWith('Failed to delete', 'error', undefined)
})
})
describe('usePackingList — bags', () => {
beforeEach(() => {
seedStore(useAddonStore, { bagTracking: true, loaded: true })
})
it('FE-W5HOOK-028: a bag list response without bags falls back to an empty list', async () => {
server.use(http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({})))
const { result } = renderPanel()
await settled()
expect(result.current.bagTrackingEnabled).toBe(true)
expect(result.current.bags).toEqual([])
})
it('FE-W5HOOK-029: creating a bag without a name does nothing', async () => {
let posted = false
server.use(http.post('/api/trips/1/packing/bags', () => { posted = true; return HttpResponse.json({ bag: {} }) }))
const { result } = renderPanel()
act(() => result.current.setNewBagName(' '))
await act(async () => { await result.current.handleCreateBag() })
expect(posted).toBe(false)
})
it('FE-W5HOOK-030: creating a bag appends it and clears the form', async () => {
server.use(
http.post('/api/trips/1/packing/bags', () =>
HttpResponse.json({ bag: { id: 3, name: 'Duffel', color: '#6366f1' } })),
)
const { result } = renderPanel()
await settled()
act(() => { result.current.setNewBagName('Duffel'); result.current.setShowAddBag(true) })
await act(async () => { await result.current.handleCreateBag() })
expect(result.current.bags).toEqual([{ id: 3, name: 'Duffel', color: '#6366f1' }])
expect(result.current.newBagName).toBe('')
expect(result.current.showAddBag).toBe(false)
})
it('FE-W5HOOK-031: a failing bag create surfaces a save error', async () => {
server.use(http.post('/api/trips/1/packing/bags', () => new HttpResponse(null, { status: 500 })))
const { result } = renderPanel()
act(() => result.current.setNewBagName('Duffel'))
await act(async () => { await result.current.handleCreateBag() })
expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined)
})
it('FE-W5HOOK-032: creating a bag by name returns the new bag', async () => {
server.use(
http.post('/api/trips/1/packing/bags', () =>
HttpResponse.json({ bag: { id: 4, name: 'Crate', color: '#6366f1' } })),
)
const { result } = renderPanel()
await settled()
let created: unknown
await act(async () => { created = await result.current.handleCreateBagByName('Crate') })
expect(created).toEqual({ id: 4, name: 'Crate', color: '#6366f1' })
expect(result.current.bags).toHaveLength(1)
})
it('FE-W5HOOK-033: a failing create-by-name returns undefined and warns', async () => {
server.use(http.post('/api/trips/1/packing/bags', () => new HttpResponse(null, { status: 500 })))
const { result } = renderPanel()
let created: unknown = 'unset'
await act(async () => { created = await result.current.handleCreateBagByName('Crate') })
expect(created).toBeUndefined()
expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined)
})
it('FE-W5HOOK-034: deleting a bag drops it from the list', async () => {
server.use(
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 5, name: 'A' }, { id: 6, name: 'B' }] })),
http.delete('/api/trips/1/packing/bags/5', () => HttpResponse.json({ success: true })),
)
const { result } = renderPanel()
await waitFor(() => expect(result.current.bags).toHaveLength(2))
await act(async () => { await result.current.handleDeleteBag(5) })
expect(result.current.bags.map(b => b.id)).toEqual([6])
})
it('FE-W5HOOK-035: a failing bag delete surfaces a delete error', async () => {
server.use(http.delete('/api/trips/1/packing/bags/5', () => new HttpResponse(null, { status: 500 })))
const { result } = renderPanel()
await act(async () => { await result.current.handleDeleteBag(5) })
expect(toastSpy).toHaveBeenCalledWith('Failed to delete', 'error', undefined)
})
it('FE-W5HOOK-036: updating a bag merges the response into that bag only', async () => {
server.use(
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 5, name: 'A' }, { id: 6, name: 'B' }] })),
http.put('/api/trips/1/packing/bags/6', () => HttpResponse.json({ bag: { name: 'Renamed' } })),
)
const { result } = renderPanel()
await waitFor(() => expect(result.current.bags).toHaveLength(2))
await act(async () => { await result.current.handleUpdateBag(6, { name: 'Renamed' }) })
expect(result.current.bags.map(b => b.name)).toEqual(['A', 'Renamed'])
})
it('FE-W5HOOK-037: a failing bag update surfaces a generic error', async () => {
server.use(http.put('/api/trips/1/packing/bags/6', () => new HttpResponse(null, { status: 500 })))
const { result } = renderPanel()
await act(async () => { await result.current.handleUpdateBag(6, { name: 'X' }) })
expect(toastSpy).toHaveBeenCalledWith('Error', 'error', undefined)
})
it('FE-W5HOOK-038: setting bag members replaces that bag members', async () => {
server.use(
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 5, name: 'A', members: [] }, { id: 6, name: 'B', members: [] }] })),
http.put('/api/trips/1/packing/bags/5/members', () =>
HttpResponse.json({ members: [{ user_id: 2, username: 'alice' }] })),
)
const { result } = renderPanel()
await waitFor(() => expect(result.current.bags).toHaveLength(2))
await act(async () => { await result.current.handleSetBagMembers(5, [2]) })
expect(result.current.bags[0].members).toEqual([{ user_id: 2, username: 'alice' }])
expect(result.current.bags[1].members).toEqual([])
})
it('FE-W5HOOK-039: a failing member update surfaces a generic error', async () => {
server.use(http.put('/api/trips/1/packing/bags/5/members', () => new HttpResponse(null, { status: 500 })))
const { result } = renderPanel()
await act(async () => { await result.current.handleSetBagMembers(5, [2]) })
expect(toastSpy).toHaveBeenCalledWith('Error', 'error', undefined)
})
})
describe('usePackingList — templates, import and signals', () => {
it('FE-W5HOOK-040: a template list response without templates falls back to an empty list', async () => {
server.use(http.get('/api/trips/:id/packing/templates', () => HttpResponse.json({})))
const { result } = renderPanel()
await settled()
expect(result.current.availableTemplates).toEqual([])
})
it('FE-W5HOOK-041: applying a template appends its items and reports the count', async () => {
const fresh = buildPackingItem({ id: 61, name: 'Towel' })
server.use(http.post('/api/trips/1/packing/apply-template/2', () => HttpResponse.json({ items: [fresh], count: 1 })))
const { result } = renderPanel()
act(() => result.current.setShowTemplateDropdown(true))
await act(async () => { await result.current.handleApplyTemplate(2) })
expect(useTripStore.getState().packingItems).toContainEqual(fresh)
expect(toastSpy).toHaveBeenCalledWith('1 items added from template', 'success', undefined)
expect(result.current.showTemplateDropdown).toBe(false)
expect(result.current.applyingTemplate).toBe(false)
})
it('FE-W5HOOK-041a: a template response without items leaves the store untouched', async () => {
server.use(http.post('/api/trips/1/packing/apply-template/2', () => HttpResponse.json({ count: 0 })))
const { result } = renderPanel()
await act(async () => { await result.current.handleApplyTemplate(2) })
expect(useTripStore.getState().packingItems).toEqual([])
expect(toastSpy).toHaveBeenCalledWith('0 items added from template', 'success', undefined)
})
it('FE-W5HOOK-042: a failing template apply surfaces a template error', async () => {
server.use(http.post('/api/trips/1/packing/apply-template/2', () => new HttpResponse(null, { status: 500 })))
const { result } = renderPanel()
await act(async () => { await result.current.handleApplyTemplate(2) })
expect(toastSpy).toHaveBeenCalledWith('Failed to apply template', 'error', undefined)
expect(result.current.applyingTemplate).toBe(false)
})
it('FE-W5HOOK-043: saving a template without a name does nothing', async () => {
let posted = false
server.use(http.post('/api/trips/1/packing/save-as-template', () => { posted = true; return HttpResponse.json({}) }))
const { result } = renderPanel()
await act(async () => { await result.current.handleSaveAsTemplate() })
expect(posted).toBe(false)
})
it('FE-W5HOOK-044: saving a template reloads the list and clears the form', async () => {
server.use(
http.post('/api/trips/1/packing/save-as-template', () => HttpResponse.json({ success: true })),
// The reload response omits `templates` — the hook must fall back to [].
http.get('/api/trips/:id/packing/templates', () => HttpResponse.json({})),
)
const { result } = renderPanel()
act(() => { result.current.setSaveTemplateName('Beach'); result.current.setShowSaveTemplate(true) })
await act(async () => { await result.current.handleSaveAsTemplate() })
expect(toastSpy).toHaveBeenCalledWith('Packing list saved as template', 'success', undefined)
expect(result.current.showSaveTemplate).toBe(false)
expect(result.current.saveTemplateName).toBe('')
await waitFor(() => expect(result.current.availableTemplates).toEqual([]))
})
it('FE-W5HOOK-045: a failing template save surfaces a generic error', async () => {
server.use(http.post('/api/trips/1/packing/save-as-template', () => new HttpResponse(null, { status: 500 })))
const { result } = renderPanel()
act(() => result.current.setSaveTemplateName('Beach'))
await act(async () => { await result.current.handleSaveAsTemplate() })
expect(toastSpy).toHaveBeenCalledWith('Error', 'error', undefined)
})
it('FE-W5HOOK-046: importing nothing reports an empty import', async () => {
const { result } = renderPanel()
await act(async () => { await result.current.handleBulkImport() })
expect(toastSpy).toHaveBeenCalledWith('No items to import', 'error', undefined)
})
it('FE-W5HOOK-047: a successful import appends the items and closes the modal', async () => {
const imported = buildPackingItem({ id: 71, name: 'Rope' })
server.use(http.post('/api/trips/1/packing/import', () => HttpResponse.json({ items: [imported], count: 1 })))
const { result } = renderPanel()
act(() => { result.current.setImportText('Gear, Rope'); result.current.setShowImportModal(true) })
await act(async () => { await result.current.handleBulkImport() })
expect(useTripStore.getState().packingItems).toContainEqual(imported)
expect(toastSpy).toHaveBeenCalledWith('1 items imported', 'success', undefined)
expect(result.current.importText).toBe('')
expect(result.current.showImportModal).toBe(false)
})
it('FE-W5HOOK-047a: an import response without items still reports success', async () => {
server.use(http.post('/api/trips/1/packing/import', () => HttpResponse.json({ count: 0 })))
const { result } = renderPanel()
act(() => result.current.setImportText('Gear, Rope'))
await act(async () => { await result.current.handleBulkImport() })
expect(useTripStore.getState().packingItems).toEqual([])
expect(toastSpy).toHaveBeenCalledWith('0 items imported', 'success', undefined)
})
it('FE-W5HOOK-048: a failing import surfaces an import error', async () => {
server.use(http.post('/api/trips/1/packing/import', () => new HttpResponse(null, { status: 500 })))
const { result } = renderPanel()
act(() => result.current.setImportText('Gear, Rope'))
await act(async () => { await result.current.handleBulkImport() })
expect(toastSpy).toHaveBeenCalledWith('Import failed', 'error', undefined)
})
it('FE-W5HOOK-049: picking a CSV file loads its text into the import box', async () => {
const { result } = renderPanel()
const file = new File(['Gear, Rope'], 'list.csv', { type: 'text/plain' })
const target = { files: [file], value: 'list.csv' } as unknown as HTMLInputElement
act(() => result.current.handleCsvFile({ target } as unknown as ChangeEvent<HTMLInputElement>))
await waitFor(() => expect(result.current.importText).toBe('Gear, Rope'))
expect(target.value).toBe('')
})
it('FE-W5HOOK-050: an empty file picker selection is ignored', async () => {
const { result } = renderPanel()
const target = { files: null, value: 'x' } as unknown as HTMLInputElement
act(() => result.current.handleCsvFile({ target } as unknown as ChangeEvent<HTMLInputElement>))
await settled()
expect(result.current.importText).toBe('')
// The input is only reset once a file was actually taken.
expect(target.value).toBe('x')
})
it('FE-W5HOOK-051: a non-text read result is ignored', async () => {
class BinaryReader {
result: unknown = new ArrayBuffer(4)
onload: (() => void) | null = null
readAsText() { this.onload?.() }
}
vi.stubGlobal('FileReader', BinaryReader)
const { result } = renderPanel()
const file = new File(['x'], 'list.csv')
const target = { files: [file], value: 'list.csv' } as unknown as HTMLInputElement
act(() => result.current.handleCsvFile({ target } as unknown as ChangeEvent<HTMLInputElement>))
await settled()
expect(result.current.importText).toBe('')
})
it('FE-W5HOOK-052: a raised import signal opens the import modal', async () => {
const { result, rerender } = renderPanel({ openImportSignal: 0 })
await settled()
rerender({ tripId: 1, items: [], openImportSignal: 1 })
expect(result.current.showImportModal).toBe(true)
})
it('FE-W5HOOK-053: a raised save-template signal opens the save form', async () => {
const { result, rerender } = renderPanel({ saveTemplateSignal: 0 })
await settled()
rerender({ tripId: 1, items: [], saveTemplateSignal: 1 })
expect(result.current.showSaveTemplate).toBe(true)
})
it('FE-W5HOOK-054: a raised add-list signal opens the list name field', async () => {
const { result, rerender } = renderPanel({ items: [], addCategorySignal: 0 })
await settled()
expect(result.current.addingCategory).toBe(false)
rerender({ tripId: 1, items: [], addCategorySignal: 1 })
expect(result.current.addingCategory).toBe(true)
})
it('FE-W5HOOK-055: a mousedown outside the template dropdown closes it', async () => {
const { result } = renderPanel()
await settled()
act(() => { result.current.setShowTemplateDropdown(true) })
act(() => { result.current.templateDropdownRef.current = document.createElement('div') })
act(() => { document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })
expect(result.current.showTemplateDropdown).toBe(false)
})
it('FE-W5HOOK-055a: a mousedown inside the template dropdown keeps it open', async () => {
const { result } = renderPanel()
await settled()
const dropdown = document.createElement('div')
const child = document.createElement('button')
dropdown.appendChild(child)
document.body.appendChild(dropdown)
act(() => { result.current.setShowTemplateDropdown(true) })
act(() => { result.current.templateDropdownRef.current = dropdown })
act(() => { child.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })
expect(result.current.showTemplateDropdown).toBe(true)
document.body.removeChild(dropdown)
})
})
describe('usePackingList — permissions and sharing', () => {
it('FE-W5HOOK-056: a restricted packing_edit permission removes edit rights', async () => {
usePermissionsStore.setState({ permissions: { packing_edit: 'admin' } })
seedStore(useAuthStore, { user: buildUser({ id: 2 }), isAuthenticated: true })
const { result } = renderPanel()
await settled()
expect(result.current.canEdit).toBe(false)
expect(result.current.isAdmin).toBe(false)
})
it('FE-W5HOOK-057: an admin keeps edit rights and the admin flag', async () => {
usePermissionsStore.setState({ permissions: { packing_edit: 'admin' } })
seedStore(useAuthStore, { user: buildAdmin({ id: 3 }), isAuthenticated: true })
const { result } = renderPanel()
await settled()
expect(result.current.canEdit).toBe(true)
expect(result.current.isAdmin).toBe(true)
expect(result.current.currentUserId).toBe(3)
})
it('FE-W5HOOK-058: the sharing handlers delegate to the packing endpoints', async () => {
const item = buildPackingItem({ id: 91, name: 'Stove' })
seedStore(useTripStore, { packingItems: [item] })
let sharingBody: Record<string, unknown> | null = null
const hits: string[] = []
server.use(
http.put('/api/trips/1/packing/91/sharing', async ({ request }) => {
sharingBody = (await request.json()) as Record<string, unknown>
return HttpResponse.json({ item })
}),
http.post('/api/trips/1/packing/91/clone', () => { hits.push('clone'); return HttpResponse.json({ item }) }),
http.post('/api/trips/1/packing/91/contributors', () => { hits.push('join'); return HttpResponse.json({ item }) }),
http.delete('/api/trips/1/packing/91/contributors/2', () => { hits.push('leave'); return HttpResponse.json({ item }) }),
)
const { result } = renderPanel({ items: [item] })
await act(async () => { await result.current.handleSetSharing(91, 'shared', [2]) })
await act(async () => { await result.current.handleCloneItem(91) })
await act(async () => { await result.current.handleJoinItem(91) })
await act(async () => { await result.current.handleLeaveItem(91, 2) })
expect(sharingBody).toEqual({ visibility: 'shared', recipient_ids: [2] })
expect(hits).toEqual(['clone', 'join', 'leave'])
})
})