Files
TREK/client/tests/unit/mobile/trip/MCollabNotes.test.tsx
T
Maurice 7298285a43 refactor(collab): share the chat logic between desktop and phone
collabModel moves from the phone tabs into components/Collab so both
shells read it, and the desktop chat drops its own copies of the time,
date separator and emoji only helpers for the model's.

useCollabChatData holds the messages, paging, live updates, the draft with
its images, and sending, deleting and reacting. The desktop hook keeps its
emoji picker, hover and delete animation, the phone tab its long press
popover; where they differed (older page errors, scrolling after a remote
delete, upload progress, the edit permission on send) the hook takes an
option.
2026-10-09 15:19:43 +02:00

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/components/Collab/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')
})
})