Files
TREK/client/tests/unit/mobile/trip/MFileLinkSheet.test.tsx
T
Maurice 92c48fba22 fix(files): file notes, links, stars and the trash changed offline wait in the queue and sync once the connection is back
Every file write went straight to the API, so offline a star snapped back
without a word and moving a file to the trash failed with "Failed to delete
file". Notes, the place or booking of a file, a further link, the star, the
trash move, restore, delete for good and emptying the trash now go through
fileRepo and the mutation queue like the other trip data: the local copy
changes at once, and an online write to a file that still has an older one
queued joins the queue behind it, since file writes carry no version token.
The desktop file manager, its assign dialog and the phone's files tab,
menu, link and trash sheets all write through it.

The single file answers carry no ids of further links, so a replayed edit,
star or restore keeps the ones the local copy holds, and the link answer
({ success, links }) writes no row.

What only works online says so instead of failing: an upload shows that it
needs a connection, a place saved with newly picked files is stopped with a
hint, and removing a further link (its row id is known only to the server)
asks to try again once online.

Refs #2601
2026-10-12 19:32:16 +02:00

196 lines
9.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import MFileLinkSheet from '../../../../src/mobile/screens/trip/tabs/MFileLinkSheet'
import { filesApi } from '../../../../src/api/client'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { Place, Reservation, TripFile } from '../../../../src/types'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-FLINK-001 to FE-MOB-FLINK-018
const PLACES = [
{ id: 11, name: 'Fushimi Inari' },
{ id: 12, name: 'Nara Park' },
] as unknown as Place[]
const RESERVATIONS = [
{ id: 21, type: 'hotel', title: 'Hotel Granvia' },
{ id: 22, type: 'train', title: 'Shinkansen Nozomi 21' },
] as unknown as Reservation[]
function file(overrides: Partial<TripFile> = {}): TripFile {
return {
id: 7, trip_id: 1, filename: 'ticket.pdf', original_name: 'ticket.pdf',
mime_type: 'application/pdf', url: '/api/trips/1/files/7/download',
created_at: '2026-05-01T12:00:00.000Z',
...overrides,
} as unknown as TripFile
}
function renderSheet(fileArg: TripFile | null = file(), plannerOverrides: Partial<TripPlanner> = {}) {
const planner = buildPlanner({
places: PLACES, reservations: RESERVATIONS, ...plannerOverrides,
} as unknown as Partial<TripPlanner>)
const onClose = vi.fn()
const view = render(<MFileLinkSheet planner={planner} file={fileArg} onClose={onClose} />)
return { ...view, planner, onClose }
}
describe('MFileLinkSheet', () => {
beforeEach(() => {
vi.spyOn(filesApi, 'update').mockResolvedValue({})
vi.spyOn(filesApi, 'addLink').mockResolvedValue({})
vi.spyOn(filesApi, 'removeLink').mockResolvedValue({})
vi.spyOn(filesApi, 'getLinks').mockResolvedValue({ links: [] })
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-MOB-FLINK-001: stays closed without a file', () => {
renderSheet(null)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-FLINK-002: heads the sheet with the file name and lists both sections', () => {
renderSheet()
expect(screen.getByRole('dialog', { name: 'files.linkTitle' })).toBeInTheDocument()
expect(screen.getByText('ticket.pdf')).toBeInTheDocument()
expect(screen.getByText('files.assignPlace')).toBeInTheDocument()
expect(screen.getByText('files.assignBooking')).toBeInTheDocument()
expect(screen.getByText('files.assignTransport')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Fushimi Inari' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Hotel Granvia' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Shinkansen Nozomi 21' })).toBeInTheDocument()
})
it('FE-MOB-FLINK-003: shows the empty hint when the trip has nothing to link to', () => {
renderSheet(file(), { places: [], reservations: [] } as unknown as Partial<TripPlanner>)
expect(screen.getByText('files.linkEmpty')).toBeInTheDocument()
expect(screen.queryByText('files.assignPlace')).not.toBeInTheDocument()
})
it('FE-MOB-FLINK-004: marks the primary and the extra links as active', () => {
renderSheet(file({ place_id: 11, linked_place_ids: [12], reservation_id: 21, linked_reservation_ids: [22] }))
for (const name of ['Fushimi Inari', 'Nara Park', 'Hotel Granvia', 'Shinkansen Nozomi 21']) {
expect(screen.getByRole('button', { name }).querySelector('.lucide-check')).not.toBeNull()
}
})
it('FE-MOB-FLINK-005: the first place link goes into place_id', async () => {
const { planner } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Fushimi Inari' }))
await waitFor(() => expect(filesApi.update).toHaveBeenCalledWith(1, 7, { place_id: 11 }))
await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1))
expect(filesApi.addLink).not.toHaveBeenCalled()
})
it('FE-MOB-FLINK-006: a further place becomes a file_link record', async () => {
renderSheet(file({ place_id: 11 }))
fireEvent.click(screen.getByRole('button', { name: 'Nara Park' }))
await waitFor(() => expect(filesApi.addLink).toHaveBeenCalledWith(1, 7, { place_id: 12 }))
expect(filesApi.update).not.toHaveBeenCalled()
})
it('FE-MOB-FLINK-007: unlinking the primary place clears place_id', async () => {
renderSheet(file({ place_id: 11 }))
fireEvent.click(screen.getByRole('button', { name: 'Fushimi Inari' }))
await waitFor(() => expect(filesApi.update).toHaveBeenCalledWith(1, 7, { place_id: null }))
})
it('FE-MOB-FLINK-008: unlinking an extra place removes its link record', async () => {
vi.mocked(filesApi.getLinks).mockResolvedValue({ links: [{ id: 99, place_id: '12' }] })
renderSheet(file({ place_id: 11, linked_place_ids: [12] }))
fireEvent.click(screen.getByRole('button', { name: 'Nara Park' }))
await waitFor(() => expect(filesApi.removeLink).toHaveBeenCalledWith(1, 7, 99))
expect(filesApi.update).not.toHaveBeenCalled()
})
it('FE-MOB-FLINK-009: a missing link record is a no-op that still refreshes', async () => {
vi.mocked(filesApi.getLinks).mockResolvedValue({})
const { planner } = renderSheet(file({ place_id: 11, linked_place_ids: [12] }))
fireEvent.click(screen.getByRole('button', { name: 'Nara Park' }))
await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1))
expect(filesApi.removeLink).not.toHaveBeenCalled()
})
it('FE-MOB-FLINK-010: the first reservation link goes into reservation_id', async () => {
const { planner } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Hotel Granvia' }))
await waitFor(() => expect(filesApi.update).toHaveBeenCalledWith(1, 7, { reservation_id: 21 }))
await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1))
})
it('FE-MOB-FLINK-011: a further reservation becomes a file_link record', async () => {
renderSheet(file({ reservation_id: 21 }))
fireEvent.click(screen.getByRole('button', { name: 'Shinkansen Nozomi 21' }))
await waitFor(() => expect(filesApi.addLink).toHaveBeenCalledWith(1, 7, { reservation_id: 22 }))
})
it('FE-MOB-FLINK-012: unlinking the primary reservation clears reservation_id', async () => {
renderSheet(file({ reservation_id: 21 }))
fireEvent.click(screen.getByRole('button', { name: 'Hotel Granvia' }))
await waitFor(() => expect(filesApi.update).toHaveBeenCalledWith(1, 7, { reservation_id: null }))
})
it('FE-MOB-FLINK-013: unlinking an extra reservation removes its link record', async () => {
vi.mocked(filesApi.getLinks).mockResolvedValue({ links: [{ id: 77, reservation_id: 22 }] })
renderSheet(file({ reservation_id: 21, linked_reservation_ids: [22] }))
fireEvent.click(screen.getByRole('button', { name: 'Shinkansen Nozomi 21' }))
await waitFor(() => expect(filesApi.removeLink).toHaveBeenCalledWith(1, 7, 77))
})
it('FE-MOB-FLINK-014: a failing toggle surfaces the assign error for both sections', async () => {
vi.mocked(filesApi.update).mockRejectedValue(new Error('boom'))
const { planner } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Fushimi Inari' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.assignError'))
expect(planner.tripActions.loadFiles).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Hotel Granvia' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledTimes(2))
})
it('FE-MOB-FLINK-015: one toggle at a time — the row spins and the others are ignored', async () => {
let release: () => void = () => {}
vi.mocked(filesApi.update).mockImplementation(
() => new Promise(resolve => { release = () => resolve({}) }),
)
renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Fushimi Inari' }))
const row = screen.getByRole('button', { name: 'Fushimi Inari' })
await waitFor(() => expect(row).toBeDisabled())
expect(row.querySelector('.animate-spin')).not.toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Nara Park' }))
fireEvent.click(screen.getByRole('button', { name: 'Hotel Granvia' }))
expect(filesApi.update).toHaveBeenCalledTimes(1)
await act(async () => { release() })
await waitFor(() => expect(screen.getByRole('button', { name: 'Fushimi Inari' })).not.toBeDisabled())
})
it('FE-MOB-FLINK-016: skips null entries in the linked id arrays', () => {
renderSheet(file({ linked_place_ids: [null, 12], linked_reservation_ids: [null, 22] }))
expect(screen.getByRole('button', { name: 'Nara Park' }).querySelector('.lucide-check')).not.toBeNull()
expect(screen.getByRole('button', { name: 'Shinkansen Nozomi 21' }).querySelector('.lucide-check')).not.toBeNull()
expect(screen.getByRole('button', { name: 'Fushimi Inari' }).querySelector('.lucide-check')).toBeNull()
})
it('FE-MOB-FLINK-017: a links response without a links array is treated as empty', async () => {
vi.mocked(filesApi.getLinks).mockResolvedValue({})
const { planner } = renderSheet(file({ reservation_id: 21, linked_reservation_ids: [22] }))
fireEvent.click(screen.getByRole('button', { name: 'Shinkansen Nozomi 21' }))
await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1))
expect(filesApi.removeLink).not.toHaveBeenCalled()
})
it('FE-MOB-FLINK-018: keeps the file mounted through the exit animation', () => {
const planner = buildPlanner({ places: PLACES, reservations: RESERVATIONS } as unknown as Partial<TripPlanner>)
const { rerender } = render(<MFileLinkSheet planner={planner} file={file()} onClose={vi.fn()} />)
rerender(<MFileLinkSheet planner={planner} file={null} onClose={vi.fn()} />)
expect(screen.getByText('ticket.pdf')).toBeInTheDocument()
})
})