Files
TREK/client/src/store/collectionStore.test.ts
T
Maurice 72a82b3c44 fix(client): work through the findings from the coverage pass
214 items came out of writing the tests. Real defects are corrected, dead code
is removed rather than covered, and the tests that pinned the old behaviour
move with them.

Also drops two agent coverage scratch files that slipped into the coverage PR
and adds the ignore rule that keeps them out.
2026-07-29 12:35:00 +02:00

923 lines
35 KiB
TypeScript

// FE-STORE-COLLECTION-001 to FE-STORE-COLLECTION-062
//
// The store owns the optimistic updates (status, labels, reorder, bulk delete) and the
// reload chains that follow every mutation, so the API layer is mocked here and the
// assertions are about resulting state + which endpoint got which arguments.
import { describe, it, expect, beforeEach, vi } from 'vitest'
import type {
Collection,
CollectionDetailResponse,
CollectionListResponse,
CollectionPlace,
} from '@trek/shared'
vi.mock('../api/collections', () => ({
collectionsApi: {
list: vi.fn(),
get: vi.fn(),
create: vi.fn(),
update: vi.fn(),
uploadCover: vi.fn(),
remove: vi.fn(),
reorder: vi.fn(),
savePlace: vi.fn(),
updatePlace: vi.fn(),
uploadPlaceImage: vi.fn(),
setStatus: vi.fn(),
ratePlace: vi.fn(),
deletePlace: vi.fn(),
deleteMany: vi.fn(),
copyToTrip: vi.fn(),
createLabel: vi.fn(),
updateLabel: vi.fn(),
deleteLabel: vi.fn(),
assignLabels: vi.fn(),
unassignLabels: vi.fn(),
invite: vi.fn(),
setMemberRole: vi.fn(),
acceptInvite: vi.fn(),
declineInvite: vi.fn(),
cancelInvite: vi.fn(),
removeMember: vi.fn(),
leave: vi.fn(),
},
}))
import { collectionsApi } from '../api/collections'
import { useCollectionStore, ALL_SAVED } from './collectionStore'
const listA: Collection = { id: 1, owner_id: 1, name: 'Tokyo' }
const listB: Collection = { id: 2, owner_id: 1, name: 'Kyoto' }
function buildPlace(over: Partial<CollectionPlace> = {}): CollectionPlace {
return { id: 10, collection_id: 1, name: 'Shibuya', status: 'want', ...over }
}
function detail(over: Partial<CollectionDetailResponse> = {}): CollectionDetailResponse {
return { collection: listA, places: [], ...over }
}
function listResponse(over: Partial<CollectionListResponse> = {}): CollectionListResponse {
return { collections: [listA], incomingInvites: [], ...over }
}
const store = () => useCollectionStore.getState()
const initialState = useCollectionStore.getState()
beforeEach(() => {
useCollectionStore.setState(initialState, true)
vi.clearAllMocks()
vi.mocked(collectionsApi.list).mockResolvedValue(listResponse())
vi.mocked(collectionsApi.get).mockResolvedValue(detail())
vi.mocked(collectionsApi.create).mockResolvedValue({ collection: listA })
vi.mocked(collectionsApi.update).mockResolvedValue({ collection: listA })
vi.mocked(collectionsApi.uploadCover).mockResolvedValue(listA)
vi.mocked(collectionsApi.remove).mockResolvedValue({ success: true })
vi.mocked(collectionsApi.reorder).mockResolvedValue({ success: true })
vi.mocked(collectionsApi.savePlace).mockResolvedValue({ place: buildPlace() })
vi.mocked(collectionsApi.updatePlace).mockResolvedValue(buildPlace())
vi.mocked(collectionsApi.uploadPlaceImage).mockResolvedValue(buildPlace())
vi.mocked(collectionsApi.setStatus).mockResolvedValue(buildPlace())
vi.mocked(collectionsApi.ratePlace).mockResolvedValue(buildPlace())
vi.mocked(collectionsApi.deletePlace).mockResolvedValue({ success: true })
vi.mocked(collectionsApi.deleteMany).mockResolvedValue({ success: true })
vi.mocked(collectionsApi.copyToTrip).mockResolvedValue({ copied: 0, skipped: [] })
vi.mocked(collectionsApi.createLabel).mockResolvedValue({ id: 3, collection_id: 1, name: 'Food' })
vi.mocked(collectionsApi.updateLabel).mockResolvedValue({ id: 3, collection_id: 1, name: 'Food' })
vi.mocked(collectionsApi.deleteLabel).mockResolvedValue({ success: true })
vi.mocked(collectionsApi.assignLabels).mockResolvedValue({ changed: 1 })
vi.mocked(collectionsApi.unassignLabels).mockResolvedValue({ changed: 1 })
vi.mocked(collectionsApi.invite).mockResolvedValue({ success: true })
vi.mocked(collectionsApi.setMemberRole).mockResolvedValue({ success: true })
vi.mocked(collectionsApi.acceptInvite).mockResolvedValue({ success: true })
vi.mocked(collectionsApi.declineInvite).mockResolvedValue({ success: true })
vi.mocked(collectionsApi.cancelInvite).mockResolvedValue({ success: true })
vi.mocked(collectionsApi.removeMember).mockResolvedValue({ success: true })
vi.mocked(collectionsApi.leave).mockResolvedValue({ success: true })
})
describe('collectionStore — loading', () => {
it('FE-STORE-COLLECTION-001: loadAll() stores collections and incoming invites', async () => {
vi.mocked(collectionsApi.list).mockResolvedValue(
listResponse({
collections: [listA, listB],
incomingInvites: [{ collection_id: 9, name: 'Osaka', from: { id: 4, username: 'bob' } }],
}),
)
await store().loadAll()
expect(store().collections).toEqual([listA, listB])
expect(store().incomingInvites[0].name).toBe('Osaka')
expect(store().loading).toBe(false)
})
it('FE-STORE-COLLECTION-002: loadAll() clears the loading flag even when the request fails', async () => {
vi.mocked(collectionsApi.list).mockRejectedValue(new Error('offline'))
await expect(store().loadAll()).rejects.toThrow('offline')
expect(store().loading).toBe(false)
expect(store().collections).toEqual([])
})
it('FE-STORE-COLLECTION-003: loadCollection() stores places, members and labels', async () => {
vi.mocked(collectionsApi.get).mockResolvedValue(
detail({
collection: {
...listA,
members: [{ user_id: 5, username: 'bob', status: 'accepted' }],
labels: [{ id: 3, collection_id: 1, name: 'Food' }],
},
places: [buildPlace()],
}),
)
await store().loadCollection(1)
expect(store().activeId).toBe(1)
expect(store().places).toHaveLength(1)
expect(store().members[0].username).toBe('bob')
expect(store().labels[0].name).toBe('Food')
expect(store().placesLoading).toBe(false)
})
it('FE-STORE-COLLECTION-004: loadCollection() falls back to empty members and labels', async () => {
vi.mocked(collectionsApi.get).mockResolvedValue(detail({ places: [buildPlace()] }))
await store().loadCollection(1)
expect(store().members).toEqual([])
expect(store().labels).toEqual([])
})
it('FE-STORE-COLLECTION-005: loadCollection() clears the active list when the fetch fails for it', async () => {
useCollectionStore.setState({ activeId: 1, places: [buildPlace()], members: [], labels: [] })
vi.mocked(collectionsApi.get).mockRejectedValue(new Error('403'))
await store().loadCollection(1)
expect(store().activeId).toBeNull()
expect(store().places).toEqual([])
expect(store().placesLoading).toBe(false)
})
it('FE-STORE-COLLECTION-006: loadCollection() keeps state when a different list fails', async () => {
useCollectionStore.setState({ activeId: 2, places: [buildPlace()] })
vi.mocked(collectionsApi.get).mockRejectedValue(new Error('403'))
await store().loadCollection(1)
expect(store().activeId).toBe(2)
expect(store().places).toHaveLength(1)
})
})
describe('collectionStore — setActive', () => {
it('FE-STORE-COLLECTION-007: setActive(null) clears the list and the selection', async () => {
useCollectionStore.setState({
activeId: 1,
places: [buildPlace()],
members: [{ user_id: 5, username: 'bob', status: 'accepted' }],
labels: [{ id: 3, collection_id: 1, name: 'Food' }],
selectMode: true,
selectedIds: [10],
selectedPlaceId: 10,
labelFilter: [3],
})
await store().setActive(null)
expect(store().activeId).toBeNull()
expect(store().places).toEqual([])
expect(store().members).toEqual([])
expect(store().labels).toEqual([])
expect(store().selectMode).toBe(false)
expect(store().selectedIds).toEqual([])
expect(store().selectedPlaceId).toBeNull()
expect(store().labelFilter).toEqual([])
expect(collectionsApi.get).not.toHaveBeenCalled()
})
it('FE-STORE-COLLECTION-008: setActive(ALL_SAVED) unions every list, dedups and skips failures', async () => {
useCollectionStore.setState({ collections: [listA, listB, { id: 3, owner_id: 1, name: 'Nara' }] })
vi.mocked(collectionsApi.get).mockImplementation(async (id: number) => {
if (id === 1) return detail({ places: [buildPlace({ id: 10 }), buildPlace({ id: 11 })] })
if (id === 2) return detail({ places: [buildPlace({ id: 11 }), buildPlace({ id: 12 })] })
throw new Error('403')
})
await store().setActive(ALL_SAVED)
expect(store().activeId).toBe(ALL_SAVED)
expect(store().places.map(p => p.id)).toEqual([10, 11, 12])
expect(store().members).toEqual([])
expect(store().placesLoading).toBe(false)
})
it('FE-STORE-COLLECTION-009: setActive(ALL_SAVED) loads the lists first when none are known yet', async () => {
vi.mocked(collectionsApi.list).mockResolvedValue(listResponse({ collections: [listB] }))
vi.mocked(collectionsApi.get).mockResolvedValue(detail({ places: [buildPlace({ id: 21 })] }))
await store().setActive(ALL_SAVED)
expect(collectionsApi.list).toHaveBeenCalled()
expect(collectionsApi.get).toHaveBeenCalledWith(2)
expect(store().places.map(p => p.id)).toEqual([21])
})
it('FE-STORE-COLLECTION-010: setActive(id) delegates to loadCollection', async () => {
vi.mocked(collectionsApi.get).mockResolvedValue(detail({ places: [buildPlace()] }))
await store().setActive(1)
expect(collectionsApi.get).toHaveBeenCalledWith(1)
expect(store().activeId).toBe(1)
})
it('FE-STORE-COLLECTION-011: refreshActive() is a no-op without an active list', async () => {
await store().refreshActive()
expect(collectionsApi.get).not.toHaveBeenCalled()
expect(collectionsApi.list).not.toHaveBeenCalled()
})
it('FE-STORE-COLLECTION-012: refreshActive() re-runs setActive for the current list', async () => {
useCollectionStore.setState({ activeId: 1 })
await store().refreshActive()
expect(collectionsApi.get).toHaveBeenCalledWith(1)
})
})
describe('collectionStore — list CRUD', () => {
it('FE-STORE-COLLECTION-013: createCollection() returns the new list and reloads', async () => {
const created = await store().createCollection({ name: 'Tokyo' })
expect(collectionsApi.create).toHaveBeenCalledWith({ name: 'Tokyo' })
expect(created).toEqual(listA)
expect(store().collections).toEqual([listA])
})
it('FE-STORE-COLLECTION-014: createCollection() returns null when the response carries no list', async () => {
vi.mocked(collectionsApi.create).mockResolvedValue({} as unknown as { collection: Collection })
expect(await store().createCollection({ name: 'Tokyo' })).toBeNull()
})
it('FE-STORE-COLLECTION-015: updateCollection() reloads the detail when the list is active', async () => {
useCollectionStore.setState({ activeId: 1 })
await store().updateCollection(1, { name: 'Tokyo 2026' })
expect(collectionsApi.update).toHaveBeenCalledWith(1, { name: 'Tokyo 2026' })
expect(collectionsApi.list).toHaveBeenCalled()
expect(collectionsApi.get).toHaveBeenCalledWith(1)
})
it('FE-STORE-COLLECTION-016: updateCollection() skips the detail reload for a non-active list', async () => {
useCollectionStore.setState({ activeId: 2 })
await store().updateCollection(1, { name: 'Tokyo 2026' })
expect(collectionsApi.get).not.toHaveBeenCalled()
})
it('FE-STORE-COLLECTION-017: uploadCover() sends the file as a "cover" form field', async () => {
useCollectionStore.setState({ activeId: 1 })
const file = new File(['x'], 'cover.jpg', { type: 'image/jpeg' })
await store().uploadCover(1, file)
const [id, fd] = vi.mocked(collectionsApi.uploadCover).mock.calls[0]
expect(id).toBe(1)
expect(fd.get('cover')).toBe(file)
expect(collectionsApi.get).toHaveBeenCalledWith(1)
})
it('FE-STORE-COLLECTION-017b: uploadCover() skips the detail reload for a non-active list', async () => {
useCollectionStore.setState({ activeId: 2 })
await store().uploadCover(1, new File(['x'], 'cover.jpg'))
expect(collectionsApi.uploadCover).toHaveBeenCalled()
expect(collectionsApi.list).toHaveBeenCalled()
expect(collectionsApi.get).not.toHaveBeenCalled()
})
it('FE-STORE-COLLECTION-018: deleteCollection() clears the active list before reloading', async () => {
useCollectionStore.setState({
activeId: 1,
places: [buildPlace()],
members: [{ user_id: 5, username: 'bob', status: 'accepted' }],
labels: [{ id: 3, collection_id: 1, name: 'Food' }],
labelFilter: [3],
})
await store().deleteCollection(1)
expect(collectionsApi.remove).toHaveBeenCalledWith(1)
expect(store().activeId).toBeNull()
expect(store().places).toEqual([])
expect(store().members).toEqual([])
// Labels belong to the deleted list, so they go with it.
expect(store().labels).toEqual([])
expect(store().labelFilter).toEqual([])
expect(collectionsApi.list).toHaveBeenCalled()
})
it('FE-STORE-COLLECTION-019: deleteCollection() leaves another active list untouched', async () => {
useCollectionStore.setState({ activeId: 2, places: [buildPlace()] })
await store().deleteCollection(1)
expect(store().activeId).toBe(2)
expect(store().places).toHaveLength(1)
})
it('FE-STORE-COLLECTION-020: reorderCollections() applies the new order optimistically', async () => {
useCollectionStore.setState({ collections: [listA, listB] })
let orderDuringRequest: number[] = []
vi.mocked(collectionsApi.reorder).mockImplementation(async () => {
orderDuringRequest = store().collections.map(c => c.id)
return { success: true }
})
await store().reorderCollections([2, 1, 99])
expect(orderDuringRequest).toEqual([2, 1])
expect(collectionsApi.reorder).toHaveBeenCalledWith([2, 1, 99])
// the trailing loadAll re-syncs from the server
expect(store().collections).toEqual([listA])
})
it('FE-STORE-COLLECTION-021: reorderCollections() still reloads when the request fails', async () => {
useCollectionStore.setState({ collections: [listA, listB] })
vi.mocked(collectionsApi.reorder).mockRejectedValue(new Error('500'))
await expect(store().reorderCollections([2, 1])).rejects.toThrow('500')
expect(collectionsApi.list).toHaveBeenCalled()
})
})
describe('collectionStore — place mutations', () => {
it('FE-STORE-COLLECTION-022: setStatus() updates the place optimistically', async () => {
useCollectionStore.setState({ places: [buildPlace({ id: 10 }), buildPlace({ id: 11 })] })
await store().setStatus(10, 'visited')
expect(collectionsApi.setStatus).toHaveBeenCalledWith(10, 'visited')
expect(store().places[0].status).toBe('visited')
expect(store().places[1].status).toBe('want')
})
it('FE-STORE-COLLECTION-023: setStatus() re-reads the active list when the call fails', async () => {
useCollectionStore.setState({ activeId: 1, places: [buildPlace({ id: 10 })] })
vi.mocked(collectionsApi.setStatus).mockRejectedValue(new Error('500'))
vi.mocked(collectionsApi.get).mockResolvedValue(detail({ places: [buildPlace({ id: 10, status: 'want' })] }))
await store().setStatus(10, 'visited')
expect(collectionsApi.get).toHaveBeenCalledWith(1)
expect(store().places[0].status).toBe('want')
})
it('FE-STORE-COLLECTION-024: updatePlace() swaps in the place returned by the server', async () => {
useCollectionStore.setState({ places: [buildPlace({ id: 10 }), buildPlace({ id: 11 })] })
vi.mocked(collectionsApi.updatePlace).mockResolvedValue(buildPlace({ id: 10, name: 'Shibuya Sky' }))
await store().updatePlace(10, { name: 'Shibuya Sky' })
expect(store().places[0].name).toBe('Shibuya Sky')
expect(store().places[1].name).toBe('Shibuya')
})
it('FE-STORE-COLLECTION-025: updatePlace() leaves the list alone when nothing comes back', async () => {
useCollectionStore.setState({ places: [buildPlace({ id: 10 })] })
vi.mocked(collectionsApi.updatePlace).mockResolvedValue(undefined as unknown as CollectionPlace)
await store().updatePlace(10, { notes: 'x' })
expect(store().places).toEqual([buildPlace({ id: 10 })])
})
it('FE-STORE-COLLECTION-026: uploadPlaceImage() sends an "image" form field and swaps the place', async () => {
useCollectionStore.setState({ places: [buildPlace({ id: 10 }), buildPlace({ id: 11 })] })
const file = new File(['x'], 'p.jpg', { type: 'image/jpeg' })
vi.mocked(collectionsApi.uploadPlaceImage).mockResolvedValue(buildPlace({ id: 10, image_url: '/uploads/p.jpg' }))
await store().uploadPlaceImage(10, file)
const [pid, fd] = vi.mocked(collectionsApi.uploadPlaceImage).mock.calls[0]
expect(pid).toBe(10)
expect(fd.get('image')).toBe(file)
expect(store().places[0].image_url).toBe('/uploads/p.jpg')
expect(store().places[1].image_url).toBeUndefined()
})
it('FE-STORE-COLLECTION-026b: uploadPlaceImage() keeps the list when the server returns nothing', async () => {
useCollectionStore.setState({ places: [buildPlace({ id: 10 })] })
vi.mocked(collectionsApi.uploadPlaceImage).mockResolvedValue(undefined as unknown as CollectionPlace)
await store().uploadPlaceImage(10, new File(['x'], 'p.jpg'))
expect(store().places).toEqual([buildPlace({ id: 10 })])
})
it('FE-STORE-COLLECTION-027: ratePlace() swaps in the re-aggregated place', async () => {
useCollectionStore.setState({ places: [buildPlace({ id: 10 }), buildPlace({ id: 11 })] })
vi.mocked(collectionsApi.ratePlace).mockResolvedValue(buildPlace({ id: 10, rating_avg: 4.5, rating_count: 2 }))
await store().ratePlace(10, 5)
expect(collectionsApi.ratePlace).toHaveBeenCalledWith(10, 5)
expect(store().places[0].rating_avg).toBe(4.5)
expect(store().places[1].rating_avg).toBeUndefined()
})
it('FE-STORE-COLLECTION-028: ratePlace() keeps the list when the server returns nothing', async () => {
useCollectionStore.setState({ places: [buildPlace({ id: 10, rating_avg: 3 })] })
vi.mocked(collectionsApi.ratePlace).mockResolvedValue(undefined as unknown as CollectionPlace)
await store().ratePlace(10, null)
expect(store().places[0].rating_avg).toBe(3)
})
it('FE-STORE-COLLECTION-029: deletePlace() drops the place and refreshes the counts', async () => {
useCollectionStore.setState({ places: [buildPlace({ id: 10 }), buildPlace({ id: 11 })] })
await store().deletePlace(10)
expect(store().places.map(p => p.id)).toEqual([11])
expect(collectionsApi.deletePlace).toHaveBeenCalledWith(10)
expect(collectionsApi.list).toHaveBeenCalled()
})
it('FE-STORE-COLLECTION-030: deleteMany() drops all selected places and exits select mode', async () => {
useCollectionStore.setState({
places: [buildPlace({ id: 10 }), buildPlace({ id: 11 }), buildPlace({ id: 12 })],
selectedIds: [10, 11],
selectMode: true,
})
await store().deleteMany([10, 11])
expect(store().places.map(p => p.id)).toEqual([12])
expect(store().selectedIds).toEqual([])
expect(store().selectMode).toBe(false)
expect(collectionsApi.deleteMany).toHaveBeenCalledWith([10, 11])
})
it('FE-STORE-COLLECTION-056: deletePlace() puts the row back when the server refuses', async () => {
useCollectionStore.setState({ places: [buildPlace({ id: 10 }), buildPlace({ id: 11 })] })
vi.mocked(collectionsApi.deletePlace).mockRejectedValue(new Error('nope'))
await expect(store().deletePlace(10)).rejects.toThrow('nope')
expect(store().places.map(p => p.id)).toEqual([10, 11])
expect(collectionsApi.list).not.toHaveBeenCalled()
})
it('FE-STORE-COLLECTION-057: deleteMany() restores the rows and the selection on failure', async () => {
useCollectionStore.setState({
places: [buildPlace({ id: 10 }), buildPlace({ id: 11 })],
selectedIds: [10],
selectMode: true,
})
vi.mocked(collectionsApi.deleteMany).mockRejectedValue(new Error('nope'))
await expect(store().deleteMany([10])).rejects.toThrow('nope')
expect(store().places.map(p => p.id)).toEqual([10, 11])
expect(store().selectedIds).toEqual([10])
expect(store().selectMode).toBe(true)
})
it('FE-STORE-COLLECTION-031: copyToTrip() forwards the payload and returns the dedup report', async () => {
vi.mocked(collectionsApi.copyToTrip).mockResolvedValue({ copied: 1, skipped: [{ id: 11, name: 'Shibuya' }] })
const res = await store().copyToTrip(7, [10, 11], true)
expect(collectionsApi.copyToTrip).toHaveBeenCalledWith({ trip_id: 7, place_ids: [10, 11], force: true })
expect(res.copied).toBe(1)
expect(res.skipped).toEqual([{ id: 11, name: 'Shibuya' }])
})
it('FE-STORE-COLLECTION-032: moveToList() re-points each place and reloads the active list', async () => {
useCollectionStore.setState({
activeId: 1,
places: [buildPlace({ id: 10 }), buildPlace({ id: 11 }), buildPlace({ id: 12 })],
selectedIds: [10, 11],
selectMode: true,
})
await store().moveToList([10, 11], 2)
expect(collectionsApi.updatePlace).toHaveBeenNthCalledWith(1, 10, { collection_id: 2 })
expect(collectionsApi.updatePlace).toHaveBeenNthCalledWith(2, 11, { collection_id: 2 })
expect(store().selectedIds).toEqual([])
expect(store().selectMode).toBe(false)
expect(collectionsApi.get).toHaveBeenCalledWith(1)
})
it('FE-STORE-COLLECTION-033: moveToList() skips the detail reload on the "All saved" pseudo-list', async () => {
useCollectionStore.setState({ activeId: ALL_SAVED, places: [buildPlace({ id: 10 })] })
await store().moveToList([10], 2)
expect(store().places).toEqual([])
expect(collectionsApi.get).not.toHaveBeenCalled()
})
it('FE-STORE-COLLECTION-058: moveToList() only drops the places that actually moved', async () => {
useCollectionStore.setState({
activeId: 1,
places: [buildPlace({ id: 10 }), buildPlace({ id: 11 }), buildPlace({ id: 12 })],
selectedIds: [10, 11],
selectMode: true,
})
vi.mocked(collectionsApi.get).mockResolvedValue(
detail({ places: [buildPlace({ id: 11 }), buildPlace({ id: 12 })] }),
)
vi.mocked(collectionsApi.updatePlace)
.mockResolvedValueOnce(buildPlace({ id: 10 }))
.mockRejectedValueOnce(new Error('nope'))
await expect(store().moveToList([10, 11], 2)).rejects.toThrow('nope')
// The list is refreshed either way, so the open list matches the server again.
expect(collectionsApi.get).toHaveBeenCalledWith(1)
expect(store().places.map(p => p.id)).toEqual([11, 12])
expect(store().selectedIds).toEqual([])
})
it('FE-STORE-COLLECTION-034: duplicateToList() re-saves each place into the target list', async () => {
const source = buildPlace({
id: 10,
name: 'Shibuya Sky',
description: 'view',
lat: 35.6,
lng: 139.7,
address: 'Shibuya',
category_id: 4,
price: 2000,
currency: 'JPY',
notes: 'sunset',
image_url: '/uploads/p.jpg',
google_place_id: 'g1',
google_ftid: 'f1',
osm_id: 'o1',
website: 'https://example.com',
phone: '+81',
status: 'visited',
links: [{ url: 'https://example.com' }],
})
useCollectionStore.setState({ places: [source], selectedIds: [10], selectMode: true })
await store().duplicateToList([10], 2)
expect(collectionsApi.savePlace).toHaveBeenCalledWith({
collection_id: 2,
name: 'Shibuya Sky',
description: 'view',
lat: 35.6,
lng: 139.7,
address: 'Shibuya',
category_id: 4,
price: 2000,
currency: 'JPY',
notes: 'sunset',
image_url: '/uploads/p.jpg',
google_place_id: 'g1',
google_ftid: 'f1',
osm_id: 'o1',
website: 'https://example.com',
phone: '+81',
status: 'visited',
links: [{ url: 'https://example.com' }],
force: true,
})
expect(store().selectedIds).toEqual([])
expect(store().selectMode).toBe(false)
})
it('FE-STORE-COLLECTION-035: duplicateToList() normalises absent fields to null and skips unknown ids', async () => {
useCollectionStore.setState({ places: [buildPlace({ id: 10 })] })
await store().duplicateToList([10, 999], 2)
expect(collectionsApi.savePlace).toHaveBeenCalledTimes(1)
expect(collectionsApi.savePlace).toHaveBeenCalledWith(
expect.objectContaining({ collection_id: 2, description: null, lat: null, lng: null, links: [] }),
)
})
})
describe('collectionStore — labels', () => {
it('FE-STORE-COLLECTION-036: createLabel() is ignored without a real active list', async () => {
useCollectionStore.setState({ activeId: ALL_SAVED })
await store().createLabel('Food', '#ef4444')
expect(collectionsApi.createLabel).not.toHaveBeenCalled()
})
it('FE-STORE-COLLECTION-037: createLabel() posts to the active list and reloads it', async () => {
useCollectionStore.setState({ activeId: 1 })
await store().createLabel('Food', '#ef4444')
expect(collectionsApi.createLabel).toHaveBeenCalledWith(1, 'Food', '#ef4444')
expect(collectionsApi.get).toHaveBeenCalledWith(1)
})
it('FE-STORE-COLLECTION-038: updateLabel() recolors optimistically before the reload', async () => {
useCollectionStore.setState({
activeId: 1,
labels: [{ id: 3, collection_id: 1, name: 'Food', color: '#ef4444' }],
})
let optimistic: string | null | undefined
vi.mocked(collectionsApi.updateLabel).mockImplementation(async () => {
optimistic = store().labels[0].color
return { id: 3, collection_id: 1, name: 'Food', color: '#22c55e' }
})
await store().updateLabel(3, { color: '#22c55e' })
expect(optimistic).toBe('#22c55e')
expect(collectionsApi.get).toHaveBeenCalledWith(1)
})
it('FE-STORE-COLLECTION-039: updateLabel() skips the reload outside a real list', async () => {
useCollectionStore.setState({
activeId: ALL_SAVED,
labels: [{ id: 3, collection_id: 1, name: 'Food' }, { id: 4, collection_id: 1, name: 'Museum' }],
})
await store().updateLabel(3, { name: 'Eats' })
expect(store().labels[0].name).toBe('Eats')
expect(store().labels[1].name).toBe('Museum')
expect(collectionsApi.get).not.toHaveBeenCalled()
})
it('FE-STORE-COLLECTION-040: deleteLabel() drops the label, its filter and its assignments', async () => {
useCollectionStore.setState({
activeId: ALL_SAVED,
labels: [{ id: 3, collection_id: 1, name: 'Food' }, { id: 4, collection_id: 1, name: 'Museum' }],
labelFilter: [3, 4],
places: [buildPlace({ id: 10, label_ids: [3, 4] }), buildPlace({ id: 11 })],
})
await store().deleteLabel(3)
expect(store().labels.map(l => l.id)).toEqual([4])
expect(store().labelFilter).toEqual([4])
expect(store().places[0].label_ids).toEqual([4])
expect(store().places[1].label_ids).toEqual([])
expect(collectionsApi.deleteLabel).toHaveBeenCalledWith(3)
expect(collectionsApi.get).not.toHaveBeenCalled()
})
it('FE-STORE-COLLECTION-040b: deleteLabel() reloads the detail when a real list is open', async () => {
useCollectionStore.setState({ activeId: 1, labels: [{ id: 3, collection_id: 1, name: 'Food' }] })
await store().deleteLabel(3)
expect(collectionsApi.get).toHaveBeenCalledWith(1)
})
it('FE-STORE-COLLECTION-041: assignLabels() adds the labels to the selected places only', async () => {
useCollectionStore.setState({
activeId: 1,
places: [buildPlace({ id: 10, label_ids: [3] }), buildPlace({ id: 11 }), buildPlace({ id: 12 })],
})
// The trailing loadCollection() overwrites places, so read the optimistic
// state while the request is still in flight.
let optimistic: CollectionPlace[] = []
vi.mocked(collectionsApi.assignLabels).mockImplementation(async () => {
optimistic = store().places
return { changed: 1 }
})
await store().assignLabels([3, 4], [10, 12])
expect(optimistic[0].label_ids).toEqual([3, 4])
expect(optimistic[1].label_ids).toBeUndefined()
// a place with no labels yet starts from an empty set
expect(optimistic[2].label_ids).toEqual([3, 4])
expect(collectionsApi.assignLabels).toHaveBeenCalledWith([3, 4], [10, 12])
expect(collectionsApi.unassignLabels).not.toHaveBeenCalled()
expect(collectionsApi.get).toHaveBeenCalledWith(1)
})
it('FE-STORE-COLLECTION-042: assignLabels(remove) strips the labels and hits the unassign endpoint', async () => {
useCollectionStore.setState({
activeId: ALL_SAVED,
places: [buildPlace({ id: 10, label_ids: [3, 4] })],
})
await store().assignLabels([3], [10], true)
expect(store().places[0].label_ids).toEqual([4])
expect(collectionsApi.unassignLabels).toHaveBeenCalledWith([3], [10])
expect(collectionsApi.assignLabels).not.toHaveBeenCalled()
})
it('FE-STORE-COLLECTION-059: updateLabel() undoes the recolor when the server refuses', async () => {
useCollectionStore.setState({ activeId: 1, labels: [{ id: 3, collection_id: 1, name: 'Food', color: '#ef4444' }] })
vi.mocked(collectionsApi.updateLabel).mockRejectedValue(new Error('nope'))
await expect(store().updateLabel(3, { color: '#22c55e' })).rejects.toThrow('nope')
expect(store().labels[0]!.color).toBe('#ef4444')
expect(collectionsApi.get).not.toHaveBeenCalled()
})
it('FE-STORE-COLLECTION-060: deleteLabel() restores the label, its filter and its assignments', async () => {
useCollectionStore.setState({
activeId: 1,
labels: [{ id: 3, collection_id: 1, name: 'Food' }],
labelFilter: [3],
places: [buildPlace({ id: 10, label_ids: [3] })],
})
vi.mocked(collectionsApi.deleteLabel).mockRejectedValue(new Error('nope'))
await expect(store().deleteLabel(3)).rejects.toThrow('nope')
expect(store().labels.map(l => l.id)).toEqual([3])
expect(store().labelFilter).toEqual([3])
expect(store().places[0]!.label_ids).toEqual([3])
})
it('FE-STORE-COLLECTION-061: assignLabels() rolls the chips back on failure', async () => {
useCollectionStore.setState({ activeId: 1, places: [buildPlace({ id: 10, label_ids: [] })] })
vi.mocked(collectionsApi.assignLabels).mockRejectedValue(new Error('nope'))
await expect(store().assignLabels([3], [10])).rejects.toThrow('nope')
expect(store().places[0]!.label_ids).toEqual([])
expect(collectionsApi.get).not.toHaveBeenCalled()
})
})
describe('collectionStore — members and invitations', () => {
it('FE-STORE-COLLECTION-043: invite() reloads the detail only for the active list', async () => {
useCollectionStore.setState({ activeId: 1 })
await store().invite(1, 5, 'editor')
expect(collectionsApi.invite).toHaveBeenCalledWith(1, 5, 'editor')
expect(collectionsApi.get).toHaveBeenCalledWith(1)
vi.mocked(collectionsApi.get).mockClear()
await store().invite(2, 6)
expect(collectionsApi.get).not.toHaveBeenCalled()
})
it('FE-STORE-COLLECTION-044: setMemberRole() reloads the detail only for the active list', async () => {
useCollectionStore.setState({ activeId: 1 })
await store().setMemberRole(1, 5, 'admin')
expect(collectionsApi.setMemberRole).toHaveBeenCalledWith(1, 5, 'admin')
expect(collectionsApi.get).toHaveBeenCalledWith(1)
vi.mocked(collectionsApi.get).mockClear()
await store().setMemberRole(2, 5, 'viewer')
expect(collectionsApi.get).not.toHaveBeenCalled()
})
it('FE-STORE-COLLECTION-045: acceptInvite() reloads the lists', async () => {
vi.mocked(collectionsApi.list).mockResolvedValue(listResponse({ collections: [listA, listB] }))
await store().acceptInvite(2)
expect(collectionsApi.acceptInvite).toHaveBeenCalledWith(2)
expect(store().collections).toHaveLength(2)
})
it('FE-STORE-COLLECTION-046: declineInvite() reloads the lists', async () => {
useCollectionStore.setState({
incomingInvites: [{ collection_id: 9, name: 'Osaka', from: { id: 4, username: 'bob' } }],
})
await store().declineInvite(9)
expect(collectionsApi.declineInvite).toHaveBeenCalledWith(9)
expect(store().incomingInvites).toEqual([])
})
it('FE-STORE-COLLECTION-047: cancelInvite() reloads the detail only for the active list', async () => {
useCollectionStore.setState({ activeId: 1 })
await store().cancelInvite(1, 5)
expect(collectionsApi.cancelInvite).toHaveBeenCalledWith(1, 5)
expect(collectionsApi.get).toHaveBeenCalledWith(1)
vi.mocked(collectionsApi.get).mockClear()
await store().cancelInvite(2, 5)
expect(collectionsApi.get).not.toHaveBeenCalled()
})
it('FE-STORE-COLLECTION-048: removeMember() reloads the detail only for the active list', async () => {
useCollectionStore.setState({ activeId: 1 })
await store().removeMember(1, 5)
expect(collectionsApi.removeMember).toHaveBeenCalledWith(1, 5)
expect(collectionsApi.get).toHaveBeenCalledWith(1)
vi.mocked(collectionsApi.get).mockClear()
await store().removeMember(2, 5)
expect(collectionsApi.get).not.toHaveBeenCalled()
})
it('FE-STORE-COLLECTION-049: leave() clears the list when it was the active one', async () => {
useCollectionStore.setState({
activeId: 1,
places: [buildPlace()],
members: [{ user_id: 5, username: 'bob', status: 'accepted' }],
labels: [{ id: 3, collection_id: 1, name: 'Food' }],
labelFilter: [3],
})
vi.mocked(collectionsApi.list).mockResolvedValue(listResponse({ collections: [] }))
await store().leave(1)
expect(collectionsApi.leave).toHaveBeenCalledWith(1)
expect(store().activeId).toBeNull()
expect(store().places).toEqual([])
expect(store().members).toEqual([])
// Labels belong to the list we just left.
expect(store().labels).toEqual([])
expect(store().labelFilter).toEqual([])
expect(store().collections).toEqual([])
})
it('FE-STORE-COLLECTION-050: leave() keeps a different active list open', async () => {
useCollectionStore.setState({ activeId: 2, places: [buildPlace()] })
await store().leave(1)
expect(store().activeId).toBe(2)
expect(store().places).toHaveLength(1)
})
})
describe('collectionStore — view state', () => {
it('FE-STORE-COLLECTION-051: the view, sort and search setters write straight through', () => {
store().setView('map')
store().setSortMode('name_asc')
store().setSearch('shibuya')
store().setSelectedPlaceId(10)
expect(store().view).toBe('map')
expect(store().sortMode).toBe('name_asc')
expect(store().search).toBe('shibuya')
expect(store().selectedPlaceId).toBe(10)
})
it('FE-STORE-COLLECTION-052: the filter setters write straight through', () => {
store().setStatusFilter('visited')
store().setCategoryFilter(4)
store().setRatingFilter(3)
store().setLabelFilter([3, 4])
expect(store().statusFilter).toBe('visited')
expect(store().categoryFilter).toBe(4)
expect(store().ratingFilter).toBe(3)
expect(store().labelFilter).toEqual([3, 4])
})
it('FE-STORE-COLLECTION-053: setSelectMode(false) also drops the current selection', () => {
useCollectionStore.setState({ selectedIds: [10, 11] })
store().setSelectMode(true)
expect(store().selectMode).toBe(true)
expect(store().selectedIds).toEqual([10, 11])
store().setSelectMode(false)
expect(store().selectMode).toBe(false)
expect(store().selectedIds).toEqual([])
})
it('FE-STORE-COLLECTION-054: toggleSelect() adds an unselected id and removes a selected one', () => {
store().toggleSelect(10)
store().toggleSelect(11)
expect(store().selectedIds).toEqual([10, 11])
store().toggleSelect(10)
expect(store().selectedIds).toEqual([11])
})
it('FE-STORE-COLLECTION-055: setSelectedIds() replaces and clearSelection() resets select mode', () => {
useCollectionStore.setState({ selectMode: true })
store().setSelectedIds([10, 11, 12])
expect(store().selectedIds).toEqual([10, 11, 12])
store().clearSelection()
expect(store().selectedIds).toEqual([])
expect(store().selectMode).toBe(false)
})
})