mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
bafcd456c5
The note website round-trips correctly through the API and the desktop grid renders it, but the mobile notes tab never carried the field: the form had no input, the payload had no key, and neither the card nor the read-only sheet showed a stored link. Since an editor only ever opens the form and a viewer only ever opens the sheet, no role on a phone could see or reach a website saved from a desktop. Editing a note on mobile did not wipe an existing link. The key was omitted rather than nulled, and the server's CASE WHEN guard kept the stored value, so this was a visibility gap rather than a destructive one. The form now always sends the field, null included, because the server only clears on an explicit non-undefined value. Every render path goes through safeExternalHref: the REST contract accepts any string, so an unguarded href would execute a stored javascript: value in this origin. The desktop expanded view had the same omission and is fixed alongside it, so a member without collab_edit no longer loses the link there either. Closes #2222
611 lines
27 KiB
TypeScript
611 lines
27 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { http, HttpResponse } from 'msw'
|
|
import MCollabNotes from '../../../../src/mobile/screens/trip/tabs/MCollabNotes'
|
|
import type { CollabNoteData } from '../../../../src/mobile/screens/trip/tabs/collabModel'
|
|
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
|
import { addListener, removeListener } from '../../../../src/api/websocket'
|
|
import { openFile } from '../../../../src/utils/fileDownload'
|
|
import { buildPlanner } from '../../../helpers/mobileTrip'
|
|
import { resetAllStores } from '../../../helpers/store'
|
|
import { server } from '../../../helpers/msw/server'
|
|
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
|
|
|
// FE-MOB-CNOTE-001 to FE-MOB-CNOTE-033
|
|
|
|
// react-markdown ships as ESM-only chunks that jsdom chokes on; the note cards
|
|
// only care that the raw text reaches the renderer.
|
|
vi.mock('react-markdown', () => ({
|
|
default: ({ children }: { children: string }) => <span data-testid="md">{children}</span>,
|
|
}))
|
|
vi.mock('remark-gfm', () => ({ default: () => ({}) }))
|
|
vi.mock('remark-breaks', () => ({ default: () => ({}) }))
|
|
vi.mock('../../../../src/utils/fileDownload', () => ({
|
|
openFile: vi.fn(),
|
|
downloadFile: vi.fn(),
|
|
}))
|
|
|
|
function note(id: number, over: Partial<CollabNoteData> = {}): CollabNoteData {
|
|
return {
|
|
id,
|
|
trip_id: 1,
|
|
user_id: 2,
|
|
title: `Note ${id}`,
|
|
content: null,
|
|
category: null,
|
|
color: null,
|
|
website: null,
|
|
pinned: 0,
|
|
username: 'Alice',
|
|
avatar: null,
|
|
avatar_url: null,
|
|
created_at: '2026-06-01T10:00:00Z',
|
|
attachments: [],
|
|
...over,
|
|
}
|
|
}
|
|
|
|
let notePages: CollabNoteData[][] = []
|
|
let getCalls = 0
|
|
let createdBodies: Record<string, unknown>[] = []
|
|
let updatedBodies: { id: string; body: Record<string, unknown> }[] = []
|
|
let deletedIds: string[] = []
|
|
let deletedFiles: string[] = []
|
|
let uploadedTo: string[] = []
|
|
|
|
/** Each argument is one GET response; the last one repeats. */
|
|
function serveNotes(...pages: CollabNoteData[][]) {
|
|
notePages = pages.length > 0 ? pages : [[]]
|
|
server.use(
|
|
http.get('/api/trips/:tripId/collab/notes', () => {
|
|
getCalls++
|
|
const page = notePages.length > 1 ? notePages.shift()! : notePages[0]
|
|
return HttpResponse.json({ notes: page })
|
|
}),
|
|
)
|
|
}
|
|
|
|
function serveWrites(opts: { createFails?: boolean; updateFails?: boolean; deleteFails?: boolean; uploadFails?: boolean } = {}) {
|
|
server.use(
|
|
http.post('/api/trips/:tripId/collab/notes', async ({ request }) => {
|
|
const body = (await request.json()) as Record<string, unknown>
|
|
createdBodies.push(body)
|
|
if (opts.createFails) return HttpResponse.json({ error: 'x' }, { status: 500 })
|
|
return HttpResponse.json({ note: note(99, { title: String(body.title), category: (body.category as string) ?? null }) })
|
|
}),
|
|
http.put('/api/trips/:tripId/collab/notes/:noteId', async ({ params, request }) => {
|
|
const body = (await request.json()) as Record<string, unknown>
|
|
updatedBodies.push({ id: String(params.noteId), body })
|
|
if (opts.updateFails) return HttpResponse.json({ error: 'x' }, { status: 500 })
|
|
return HttpResponse.json({ note: { id: Number(params.noteId), ...body } })
|
|
}),
|
|
http.delete('/api/trips/:tripId/collab/notes/:noteId', ({ params }) => {
|
|
deletedIds.push(String(params.noteId))
|
|
if (opts.deleteFails) return HttpResponse.json({ error: 'x' }, { status: 500 })
|
|
return HttpResponse.json({ success: true })
|
|
}),
|
|
http.post('/api/trips/:tripId/collab/notes/:noteId/files', ({ params }) => {
|
|
uploadedTo.push(String(params.noteId))
|
|
if (opts.uploadFails) return HttpResponse.json({ error: 'x' }, { status: 500 })
|
|
return HttpResponse.json({ file: { id: 1, filename: 'f', original_name: 'f', url: '/uploads/f' } })
|
|
}),
|
|
http.delete('/api/trips/:tripId/collab/notes/:noteId/files/:fileId', ({ params }) => {
|
|
deletedFiles.push(`${params.noteId}/${params.fileId}`)
|
|
return HttpResponse.json({ success: true })
|
|
}),
|
|
)
|
|
}
|
|
|
|
async function renderNotes(planner: TripPlanner = buildPlanner()) {
|
|
const view = render(<MCollabNotes planner={planner} />)
|
|
await waitFor(() => expect(screen.queryByText('common.loading')).not.toBeInTheDocument())
|
|
return { ...view, planner }
|
|
}
|
|
|
|
function fileInput(): HTMLInputElement {
|
|
return document.querySelector('input[type="file"]') as HTMLInputElement
|
|
}
|
|
|
|
describe('MCollabNotes', () => {
|
|
beforeEach(() => {
|
|
resetAllStores()
|
|
getCalls = 0
|
|
createdBodies = []
|
|
updatedBodies = []
|
|
deletedIds = []
|
|
deletedFiles = []
|
|
uploadedTo = []
|
|
vi.mocked(addListener).mockClear()
|
|
vi.mocked(removeListener).mockClear()
|
|
vi.mocked(openFile).mockClear()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-001: shows the mascot loading state until the notes arrive', async () => {
|
|
serveNotes([note(1, { title: 'Visa' })])
|
|
render(<MCollabNotes planner={buildPlanner()} />)
|
|
expect(screen.getByText('common.loading')).toBeInTheDocument()
|
|
expect(await screen.findByText('Visa')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-002: shows the empty state with the create affordance', async () => {
|
|
serveNotes([])
|
|
await renderNotes()
|
|
expect(screen.getByText('collab.notes.empty')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: /collab\.notes\.new/ })).toBeInTheDocument()
|
|
// no categories yet, so the filter row stays out
|
|
expect(screen.queryByText('collab.notes.all')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-003: keeps an empty list when the request fails', async () => {
|
|
server.use(http.get('/api/trips/:tripId/collab/notes', () => HttpResponse.json({ error: 'x' }, { status: 500 })))
|
|
await renderNotes()
|
|
expect(screen.getByText('collab.notes.empty')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-004: sorts pinned first, then by last touch', async () => {
|
|
serveNotes([
|
|
note(1, { title: 'Oldest', created_at: '2026-06-01T10:00:00Z' }),
|
|
note(2, { title: 'Pinned', pinned: 1, created_at: '2026-01-01T10:00:00Z' }),
|
|
note(3, { title: 'Freshest', created_at: '2026-06-02T10:00:00Z', updated_at: '2026-07-09T10:00:00Z' }),
|
|
])
|
|
await renderNotes()
|
|
const titles = screen.getAllByText(/^(Oldest|Pinned|Freshest)$/).map(el => el.textContent)
|
|
expect(titles).toEqual(['Pinned', 'Freshest', 'Oldest'])
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-005: renders body, author and attachment count on a card', async () => {
|
|
serveNotes([note(1, {
|
|
title: 'Packing tips',
|
|
content: '**bring** an adapter',
|
|
category: 'Gear',
|
|
username: 'alice',
|
|
attachments: [
|
|
{ id: 1, filename: 'a', original_name: 'a.pdf', url: '/uploads/a.pdf' },
|
|
{ id: 2, filename: 'b', original_name: 'b.pdf', url: '/uploads/b.pdf' },
|
|
],
|
|
})])
|
|
await renderNotes()
|
|
expect(screen.getByTestId('md')).toHaveTextContent('**bring** an adapter')
|
|
expect(screen.getAllByText('Gear').length).toBeGreaterThan(0)
|
|
expect(screen.getByText('A')).toBeInTheDocument()
|
|
expect(screen.getByText('alice')).toBeInTheDocument()
|
|
expect(screen.getByText('2')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-006: filters by category and clears the filter on a second tap', async () => {
|
|
serveNotes([
|
|
note(1, { title: 'Visa', category: 'Docs', color: '#ef4444' }),
|
|
note(2, { title: 'Tent', category: 'Gear', color: '#10b981' }),
|
|
])
|
|
const { container } = await renderNotes()
|
|
expect(screen.getByText('collab.notes.all')).toBeInTheDocument()
|
|
|
|
const gearPill = container.querySelectorAll('button')
|
|
const pill = Array.from(gearPill).find(b => b.textContent === 'Gear')!
|
|
fireEvent.click(pill)
|
|
expect(screen.getByText('Tent')).toBeInTheDocument()
|
|
expect(screen.queryByText('Visa')).not.toBeInTheDocument()
|
|
|
|
fireEvent.click(pill)
|
|
expect(screen.getByText('Visa')).toBeInTheDocument()
|
|
|
|
fireEvent.click(pill)
|
|
fireEvent.click(screen.getByText('collab.notes.all'))
|
|
expect(screen.getByText('Visa')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-007: pins a note and flips the affordance', async () => {
|
|
serveNotes([note(1, { title: 'Visa' })])
|
|
serveWrites()
|
|
await renderNotes()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.pin' }))
|
|
await waitFor(() => expect(updatedBodies).toEqual([{ id: '1', body: { pinned: true } }]))
|
|
expect(await screen.findByRole('button', { name: 'collab.notes.unpin' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-008: deletes only after the confirm sheet is accepted', async () => {
|
|
serveNotes([note(1, { title: 'Visa' })])
|
|
serveWrites()
|
|
await renderNotes()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.delete' }))
|
|
expect(screen.getByText('collab.notes.confirmDeleteTitle')).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
|
expect(deletedIds).toEqual([])
|
|
expect(screen.getByText('Visa')).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.delete' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
|
await waitFor(() => expect(deletedIds).toEqual(['1']))
|
|
expect(screen.queryByText('Visa')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-009: toasts when a delete is rejected and keeps the card', async () => {
|
|
serveNotes([note(1, { title: 'Visa' })])
|
|
serveWrites({ deleteFails: true })
|
|
const { planner } = await renderNotes()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.delete' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
|
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
|
|
expect(screen.getByText('Visa')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-010: creates a note with an existing category and prepends it', async () => {
|
|
serveNotes([note(1, { title: 'Visa', category: 'Docs', color: '#ef4444' })])
|
|
serveWrites()
|
|
await renderNotes()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
|
|
const dialog = screen.getByRole('dialog', { name: 'collab.notes.new' })
|
|
expect(screen.getByRole('button', { name: 'collab.notes.create' })).toBeDisabled()
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: ' Insurance ' } })
|
|
fireEvent.change(screen.getByPlaceholderText('collab.notes.contentPlaceholder'), { target: { value: ' call the hotline ' } })
|
|
fireEvent.click(Array.from(dialog.querySelectorAll('button')).find(b => b.textContent === 'Docs')!)
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
|
|
|
await waitFor(() => expect(createdBodies).toEqual([
|
|
{ title: 'Insurance', content: 'call the hotline', category: 'Docs', color: '#ef4444', website: null },
|
|
]))
|
|
expect(await screen.findByText('Insurance')).toBeInTheDocument()
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-011: a brand new category picks the first palette colour', async () => {
|
|
serveNotes([])
|
|
serveWrites()
|
|
await renderNotes()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
|
|
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Ferry' } })
|
|
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.newCategory/ }))
|
|
fireEvent.change(screen.getByPlaceholderText('collab.notes.newCategory'), { target: { value: 'Transport' } })
|
|
fireEvent.keyDown(screen.getByPlaceholderText('collab.notes.newCategory'), { key: 'Enter' })
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
|
|
|
await waitFor(() => expect(createdBodies).toEqual([
|
|
{ title: 'Ferry', category: 'Transport', color: '#6366f1', website: null },
|
|
]))
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-012: the inline category input can be confirmed with the button and ignores blanks', async () => {
|
|
serveNotes([])
|
|
serveWrites()
|
|
await renderNotes()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
|
|
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Ferry' } })
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.newCategory/ }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
|
expect(screen.queryByPlaceholderText('collab.notes.newCategory')).not.toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.newCategory/ }))
|
|
fireEvent.change(screen.getByPlaceholderText('collab.notes.newCategory'), { target: { value: 'Food' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
|
|
|
await waitFor(() => expect(createdBodies).toEqual([
|
|
{ title: 'Ferry', category: 'Food', color: '#6366f1', website: null },
|
|
]))
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-013: a failed create toasts and keeps the sheet open', async () => {
|
|
serveNotes([])
|
|
serveWrites({ createFails: true })
|
|
const { planner } = await renderNotes()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
|
|
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Ferry' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
|
|
|
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
|
|
expect(screen.getByRole('dialog', { name: 'collab.notes.new' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-014: editing prefills the sheet and saves the changed fields', async () => {
|
|
serveNotes([note(1, { title: 'Visa', content: 'apply early', category: 'Docs', color: '#ef4444' })])
|
|
serveWrites()
|
|
await renderNotes()
|
|
|
|
fireEvent.click(screen.getByText('Visa'))
|
|
expect(screen.getByRole('dialog', { name: 'collab.notes.edit' })).toBeInTheDocument()
|
|
const title = screen.getByPlaceholderText('collab.notes.titlePlaceholder')
|
|
expect(title).toHaveValue('Visa')
|
|
expect(screen.getByPlaceholderText('collab.notes.contentPlaceholder')).toHaveValue('apply early')
|
|
|
|
fireEvent.change(title, { target: { value: 'Visa (done)' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.save' }))
|
|
|
|
await waitFor(() => expect(updatedBodies).toEqual([
|
|
{ id: '1', body: { title: 'Visa (done)', content: 'apply early', category: 'Docs', color: '#ef4444', website: null } },
|
|
]))
|
|
expect(await screen.findByText('Visa (done)')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-015: a failed update toasts and leaves the card untouched', async () => {
|
|
serveNotes([note(1, { title: 'Visa' })])
|
|
serveWrites({ updateFails: true })
|
|
const { planner } = await renderNotes()
|
|
|
|
fireEvent.click(screen.getByText('Visa'))
|
|
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Nope' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.save' }))
|
|
|
|
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
|
|
expect(screen.getByText('Visa')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-016: the cancel button closes the form without writing', async () => {
|
|
serveNotes([note(1, { title: 'Visa' })])
|
|
serveWrites()
|
|
await renderNotes()
|
|
|
|
fireEvent.click(screen.getByText('Visa'))
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.cancel' }))
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
|
expect(updatedBodies).toEqual([])
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-017: queues picked files, drops one again and uploads the rest on save', async () => {
|
|
serveNotes(
|
|
[note(1, { title: 'Visa' })],
|
|
[note(1, { title: 'Visa', attachments: [{ id: 5, filename: 'p', original_name: 'passport.pdf', url: '/uploads/p' }] })],
|
|
)
|
|
serveWrites()
|
|
await renderNotes()
|
|
|
|
fireEvent.click(screen.getByText('Visa'))
|
|
fireEvent.change(fileInput(), {
|
|
target: { files: [new File(['a'], 'passport.pdf'), new File(['b'], 'a-really-long-attachment-name.pdf')] },
|
|
})
|
|
expect(screen.getByText('passport.pdf')).toBeInTheDocument()
|
|
// long names are shortened for the chip
|
|
const longChip = screen.getByText('a-really-long-att...')
|
|
|
|
fireEvent.click(within(longChip).getByRole('button'))
|
|
expect(screen.queryByText('a-really-long-att...')).not.toBeInTheDocument()
|
|
expect(screen.getByText('passport.pdf')).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.save' }))
|
|
await waitFor(() => expect(uploadedTo).toEqual(['1']))
|
|
// the list is refetched so the new attachment shows up
|
|
await waitFor(() => expect(getCalls).toBe(2))
|
|
expect(await screen.findByText('1')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-018: a failed upload toasts but still refreshes the list', async () => {
|
|
serveNotes([note(1, { title: 'Visa' })])
|
|
serveWrites({ uploadFails: true })
|
|
const { planner } = await renderNotes()
|
|
|
|
fireEvent.click(screen.getByText('Visa'))
|
|
fireEvent.change(fileInput(), { target: { files: [new File(['a'], 'x.pdf')] } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.save' }))
|
|
|
|
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
|
|
await waitFor(() => expect(getCalls).toBe(2))
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-019: removing an existing attachment calls the file endpoint', async () => {
|
|
serveNotes([note(1, {
|
|
title: 'Visa',
|
|
attachments: [{ id: 5, filename: 'p', original_name: 'passport.pdf', url: '/uploads/p' }],
|
|
})])
|
|
serveWrites()
|
|
await renderNotes()
|
|
|
|
fireEvent.click(screen.getByText('Visa'))
|
|
const chip = screen.getByText('passport.pdf')
|
|
fireEvent.click(within(chip).getByRole('button'))
|
|
|
|
await waitFor(() => expect(deletedFiles).toEqual(['1/5']))
|
|
await waitFor(() => expect(screen.queryByText('passport.pdf')).not.toBeInTheDocument())
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-020: a new note uploads its queued files against the created id', async () => {
|
|
serveNotes([], [note(99, { title: 'Ferry', attachments: [{ id: 8, filename: 't', original_name: 't.pdf', url: '/uploads/t' }] })])
|
|
serveWrites()
|
|
await renderNotes()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
|
|
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Ferry' } })
|
|
fireEvent.change(fileInput(), { target: { files: [new File(['a'], 't.pdf')] } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
|
|
|
await waitFor(() => expect(createdBodies).toHaveLength(1))
|
|
await waitFor(() => expect(uploadedTo).toEqual(['99']))
|
|
await waitFor(() => expect(getCalls).toBe(2))
|
|
expect(await screen.findByText('Ferry')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-021: keeps the chip and toasts when detaching an existing file is rejected', async () => {
|
|
serveNotes([note(1, {
|
|
title: 'Visa',
|
|
attachments: [{ id: 5, filename: 'p', original_name: 'passport.pdf', url: '/uploads/p' }],
|
|
})])
|
|
serveWrites()
|
|
server.use(http.delete('/api/trips/:tripId/collab/notes/:noteId/files/:fileId', () =>
|
|
HttpResponse.json({ error: 'x' }, { status: 500 })))
|
|
const { planner } = await renderNotes()
|
|
|
|
fireEvent.click(screen.getByText('Visa'))
|
|
fireEvent.click(within(screen.getByText('passport.pdf')).getByRole('button'))
|
|
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
|
|
expect(screen.getByText('passport.pdf')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-022: the attach tile opens the hidden file picker', async () => {
|
|
serveNotes([note(1, { title: 'Visa' })])
|
|
const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {})
|
|
await renderNotes()
|
|
|
|
fireEvent.click(screen.getByText('Visa'))
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.attachFiles' }))
|
|
expect(click).toHaveBeenCalledTimes(1)
|
|
click.mockRestore()
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-023: hides the attachment section without the upload permission', async () => {
|
|
serveNotes([note(1, { title: 'Visa' })])
|
|
const can = ((perm: string) => perm !== 'file_upload') as unknown as TripPlanner['can']
|
|
await renderNotes(buildPlanner({ can }))
|
|
|
|
fireEvent.click(screen.getByText('Visa'))
|
|
expect(screen.queryByText('collab.notes.attachFiles')).not.toBeInTheDocument()
|
|
expect(fileInput()).toBeNull()
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-024: a read-only member gets the viewer sheet and can open attachments', async () => {
|
|
serveNotes([note(1, {
|
|
title: 'Visa',
|
|
content: 'apply early',
|
|
category: 'Docs',
|
|
attachments: [{ id: 5, filename: 'p', original_name: 'passport.pdf', url: '/uploads/p' }],
|
|
})])
|
|
await renderNotes(buildPlanner({ can: (() => false) as TripPlanner['can'] }))
|
|
|
|
expect(screen.queryByRole('button', { name: /collab\.notes\.new/ })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'collab.notes.pin' })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'collab.notes.delete' })).not.toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByText('Visa'))
|
|
const viewer = screen.getByRole('dialog', { name: 'Visa' })
|
|
expect(viewer).toBeInTheDocument()
|
|
fireEvent.click(screen.getByRole('button', { name: /passport\.pdf/ }))
|
|
expect(openFile).toHaveBeenCalledWith('/uploads/p', 'passport.pdf')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-025: applies remote create, update and delete events for this trip only', async () => {
|
|
serveNotes([note(1, { title: 'Visa' })])
|
|
await renderNotes()
|
|
const handler = vi.mocked(addListener).mock.calls[0][0]
|
|
|
|
act(() => handler({ type: 'collab:note:created', tripId: 1, note: note(2, { title: 'Remote' }) }))
|
|
expect(screen.getByText('Remote')).toBeInTheDocument()
|
|
|
|
act(() => handler({ type: 'collab:note:created', tripId: 1, note: note(2, { title: 'Remote' }) }))
|
|
expect(screen.getAllByText('Remote')).toHaveLength(1)
|
|
|
|
act(() => handler({ type: 'collab:note:updated', tripId: 1, note: note(2, { title: 'Renamed' }) }))
|
|
expect(screen.getByText('Renamed')).toBeInTheDocument()
|
|
|
|
act(() => handler({ type: 'collab:note:created', tripId: 42, note: note(3, { title: 'Elsewhere' }) }))
|
|
expect(screen.queryByText('Elsewhere')).not.toBeInTheDocument()
|
|
|
|
act(() => handler({ type: 'collab:note:deleted', tripId: 1, noteId: 2 }))
|
|
expect(screen.queryByText('Renamed')).not.toBeInTheDocument()
|
|
expect(screen.getByText('Visa')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-026: detaches its websocket listener on unmount', async () => {
|
|
serveNotes([note(1)])
|
|
const { unmount } = await renderNotes()
|
|
const handler = vi.mocked(addListener).mock.calls[0][0]
|
|
|
|
unmount()
|
|
expect(removeListener).toHaveBeenCalledWith(handler)
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-027: creates a note with the trimmed website', async () => {
|
|
serveNotes([])
|
|
serveWrites()
|
|
await renderNotes()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
|
|
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Ferry' } })
|
|
fireEvent.change(screen.getByPlaceholderText('collab.notes.websitePlaceholder'), { target: { value: ' https://ferry.test/booking ' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
|
|
|
await waitFor(() => expect(createdBodies).toEqual([
|
|
{ title: 'Ferry', color: '#6366f1', website: 'https://ferry.test/booking' },
|
|
]))
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-028: a blank website is posted as null instead of an empty string', async () => {
|
|
serveNotes([])
|
|
serveWrites()
|
|
await renderNotes()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
|
|
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Ferry' } })
|
|
fireEvent.change(screen.getByPlaceholderText('collab.notes.websitePlaceholder'), { target: { value: ' ' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
|
|
|
await waitFor(() => expect(createdBodies).toEqual([
|
|
{ title: 'Ferry', color: '#6366f1', website: null },
|
|
]))
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-029: editing prefills the stored website and saves it back unchanged', async () => {
|
|
serveNotes([note(1, { title: 'Visa', website: 'https://visa.test' })])
|
|
serveWrites()
|
|
await renderNotes()
|
|
|
|
fireEvent.click(screen.getByText('Visa'))
|
|
expect(screen.getByPlaceholderText('collab.notes.websitePlaceholder')).toHaveValue('https://visa.test')
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.save' }))
|
|
|
|
await waitFor(() => expect(updatedBodies).toEqual([
|
|
{ id: '1', body: { title: 'Visa', color: '#6366f1', website: 'https://visa.test' } },
|
|
]))
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-030: clearing the website sends an explicit null so the server drops it', async () => {
|
|
serveNotes([note(1, { title: 'Visa', website: 'https://visa.test' })])
|
|
serveWrites()
|
|
await renderNotes()
|
|
|
|
fireEvent.click(screen.getByText('Visa'))
|
|
fireEvent.change(screen.getByPlaceholderText('collab.notes.websitePlaceholder'), { target: { value: '' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.save' }))
|
|
|
|
await waitFor(() => expect(updatedBodies).toEqual([
|
|
{ id: '1', body: { title: 'Visa', color: '#6366f1', website: null } },
|
|
]))
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-031: the viewer sheet links a bare host out to a new tab', async () => {
|
|
serveNotes([note(1, { title: 'Visa', website: 'www.visa.test' })])
|
|
await renderNotes(buildPlanner({ can: (() => false) as TripPlanner['can'] }))
|
|
|
|
fireEvent.click(screen.getByText('Visa'))
|
|
const link = within(screen.getByRole('dialog', { name: 'Visa' })).getByRole('link')
|
|
expect(link).toHaveAttribute('href', 'https://www.visa.test')
|
|
expect(link).toHaveAttribute('target', '_blank')
|
|
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
|
expect(link).toHaveTextContent('visa.test')
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-032: a javascript: website is never turned into a link', async () => {
|
|
serveNotes([note(1, { title: 'Visa', website: 'javascript:alert(1)' })])
|
|
await renderNotes(buildPlanner({ can: (() => false) as TripPlanner['can'] }))
|
|
|
|
expect(screen.queryAllByRole('link')).toHaveLength(0)
|
|
fireEvent.click(screen.getByText('Visa'))
|
|
expect(screen.getByRole('dialog', { name: 'Visa' })).toBeInTheDocument()
|
|
expect(screen.queryAllByRole('link')).toHaveLength(0)
|
|
})
|
|
|
|
it('FE-MOB-CNOTE-033: only a card with a website carries the link affordance', async () => {
|
|
serveNotes([
|
|
note(1, { title: 'Visa', website: 'https://visa.test' }),
|
|
note(2, { title: 'Tent' }),
|
|
])
|
|
await renderNotes()
|
|
|
|
const links = screen.getAllByRole('link', { name: 'collab.notes.website' })
|
|
expect(links).toHaveLength(1)
|
|
expect(links[0]).toHaveAttribute('href', 'https://visa.test')
|
|
})
|
|
})
|