import { expect, type APIRequestContext, type Page, test } from '@playwright/test' import { foreignServiceRecordId, organizationIdFor, plantJob, plantWorkshop, seededTenantFixtures, } from '../../support/db' import { settle } from '../../support/hydration' /** * The contract the technician app is built against. * * `/api/v1/tech/*` is consumed by a phone app that lives in another * repository and ships through two app stores, so a break here is not a * deploy away from being fixed: it is a review queue away. Only `/health` was * covered, which proves the routes are mounted and nothing else. * * The whole path is walked as the app walks it: the desk adds a technician and * reads them a setup code, the phone exchanges the code for a token, and the * token is used to list the day's work and put the clock on a job. Then the * refusals, which matter more than the successes — the token must not reach * another technician's job, and must not reach another workshop's at all, * neither to read it nor to book time against it. */ test.describe.configure({ mode: 'serial' }) const stamp = Date.now() const TECHNICIAN = `E2E Tech ${stamp}` const PHONE = `555${String(stamp).slice(-7)}` /** The outsider whose workshop provides a job this token has no business with. */ const OUTSIDER = `e2e-tech-outsider-${stamp}@example.com` const OUTSIDER_PASSWORD = `E2e-pass-${stamp}` /** The window the app asks its day summary for; the phone owns the timezone. */ const DAY = { from: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), to: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), } const ENTRIES = `/api/v1/tech/time/entries?from=${DAY.from}&to=${DAY.to}` let setupCode = '' let token = '' /** The phone's own context: no cookies, so only the token speaks for it. */ let device: APIRequestContext let jobId = '' /** A job in this workshop that belongs to a different technician. */ let someoneElsesJob = '' /** A job in another workshop altogether. */ let foreignJob = '' /** * The phone: a request context carrying nothing but the token it was given. * * A cookie-free context on purpose. The suite's own contexts are signed in as * the workshop owner, and `withApiAuth` treats the bearer header as a gate and * then resolves the session from the request's headers — so a context with the * owner's cookie in it answers as the owner however the token reads, and a * test written on it proves nothing about the token at all. */ function phone(request: APIRequestContext, bearer = token) { return { get: (url: string) => request.get(url, { headers: { authorization: `Bearer ${bearer}` } }), post: (url: string, data?: unknown) => request.post(url, { headers: { authorization: `Bearer ${bearer}` }, ...(data ? { data } : {}), }), patch: (url: string, data?: unknown) => request.patch(url, { headers: { authorization: `Bearer ${bearer}` }, ...(data ? { data } : {}), }), } } async function openTeamSettings(page: Page) { await page.goto('/settings/team') await settle(page) } test.beforeAll(async ({ browser, playwright, baseURL }) => { // An empty storage state, spelled out: a context made through the // `playwright` fixture inherits the project's, which is the workshop owner // signed in. With that cookie present the session comes back as the owner // however the bearer token reads, and every assertion below would be about // the wrong person. device = await playwright.request.newContext({ baseURL, storageState: { cookies: [], origins: [] }, }) const seeded = await seededTenantFixtures() // A job in this workshop that will not be assigned to the new technician. someoneElsesJob = seeded.serviceRecordId // A second workshop, for the cross-workshop refusals, planted with a job // of its own: a self-hosted install opens one workshop, so a sign-up would // be told to ask for an invitation instead of opening this one. const outsider = await plantWorkshop({ name: 'E2E Tech Outsider', email: OUTSIDER, password: OUTSIDER_PASSWORD, workshopName: `E2E Tech Outsider Garage ${stamp}`, }) await plantJob(outsider.organizationId, outsider.userId, `E2E Tech Outsider Job ${stamp}`) foreignJob = await foreignServiceRecordId(await organizationIdFor(OUTSIDER)) expect(foreignJob).not.toBe(someoneElsesJob) }) test.afterAll(async () => { await device?.dispose() }) test.describe('the technician app', () => { test('answers before anybody has signed in', async () => { const health = await device.get('/api/v1/tech/health') expect(health.status()).toBe(200) }) test('refuses every endpoint without a token', async () => { for (const url of [ '/api/v1/tech/me', '/api/v1/tech/jobs', ENTRIES, '/api/v1/tech/parts/lookup?barcode=1234567890128', ]) { const response = await device.get(url) expect(response.status(), `${url} without a token`).toBe(401) } const start = await device.post('/api/v1/tech/time/start', { data: { serviceRecordId: someoneElsesJob }, }) expect(start.status(), 'starting the clock without a token').toBe(401) }) test('the desk adds a technician and reads them a code', async ({ page }) => { await openTeamSettings(page) // One Add button, then a choice: the two kinds of person are set up // differently, and a mechanic is the one who gets the app. await expect(async () => { await page.getByRole('button', { name: 'Add', exact: true }).first().click() await expect(page.getByText('A mechanic')).toBeVisible({ timeout: 2_000 }) }).toPass({ timeout: 30_000 }) await page.getByText('A mechanic').click() await expect(page.getByPlaceholder('Their full name')).toBeVisible({ timeout: 10_000 }) await page.getByPlaceholder('Their full name').fill(TECHNICIAN) // A mobile number cannot be read without knowing which country's it is, // and this workshop has never said. Asked once, then remembered. const country = page .getByRole('combobox') .filter({ hasText: /choose a country/i }) .first() if (await country.isVisible().catch(() => false)) { await country.click() await page .getByRole('option', { name: /United States/i }) .first() .click() } await page.getByPlaceholder('The phone in their pocket').fill(PHONE) await page.getByRole('button', { name: 'Create', exact: true }).click() // The dialog turns into the setup instructions, with the code printed for // a technician who is not standing at the desk. await expect(page.getByText(/or read them this code/i)).toBeVisible({ timeout: 30_000 }) const codeText = await page .getByText(/^[ABCDEFGHJKLMNPQRTUVWXYZ2346789]{4}[\s-]?[ABCDEFGHJKLMNPQRTUVWXYZ2346789]{4}$/) .first() .innerText() setupCode = codeText.replace(/[^A-Z2-9]/g, '') expect(setupCode, 'the code is eight characters').toHaveLength(8) }) // The redeem endpoint is the one thing here anybody on the internet can // reach with a guess, so it allows five anonymous attempts a minute. This // file spends three of them and no more: hammering it would only prove the // limiter works, at the cost of the tests that come after. test('a code can be spent once, and only once', async () => { const redeemed = await device.post('/api/v1/tech/setup/redeem', { data: { code: setupCode } }) expect(redeemed.status()).toBe(200) const body = await redeemed.json() token = body.data.token expect(token, 'the phone is given a token').toBeTruthy() expect(body.data.workshop).toBe('Demo Auto Workshop') // Two phones scanning the same screen: exactly one of them wins. const again = await device.post('/api/v1/tech/setup/redeem', { data: { code: setupCode } }) expect(again.status()).toBe(400) expect((await again.json()).error.code).toBe('code_used') }) test('a made-up code is refused, and says nothing about who exists', async () => { const response = await device.post('/api/v1/tech/setup/redeem', { data: { code: 'ZZZZ9999' } }) // Run again inside the same minute and the limiter answers before the // code is even looked at, which is the right order for it to answer in. expect([400, 429]).toContain(response.status()) if (response.status() === 400) { const body = await response.json() // Not "no such technician", not "wrong workshop": one answer for // everything, so the endpoint cannot be used to find out who exists. expect(body.error.code).toBe('invalid_code') } }) test('says who is holding the phone, and which workshop', async () => { const me = await phone(device).get('/api/v1/tech/me') expect(me.status()).toBe(200) const { data } = await me.json() // Everything the app's first screen is built from, in one answer. expect(data.organization.name).toBe('Demo Auto Workshop') expect(data.technicians.map((t: { name: string }) => t.name)).toContain(TECHNICIAN) expect(data.isTechnician).toBe(true) expect(data.isAdmin).toBe(false) // The app refuses to run below this, so it has to keep coming back. expect(data.minAppVersion, 'the minimum version the app must meet').toBeTruthy() }) test('lists nothing until there is work assigned', async () => { const jobs = await phone(device).get('/api/v1/tech/jobs') expect(jobs.status()).toBe(200) const { data } = await jobs.json() // A technician who has just been created is assigned nothing, and the // app's home screen has to cope with that rather than with an error. expect(data.jobs).toEqual([]) // The same answer says whether a clock is already running, so the app can // draw its running bar without a second request. expect(data.openEntryJobId).toBeNull() }) test('the day’s work appears once the desk assigns it', async ({ page }) => { // Assigned from the work order's schedule card, which is where a service // adviser does it. await page.goto(`/vehicles/${(await seededTenantFixtures()).vehicleId}/service/new`) // `/service/new` creates the draft and redirects to its id, and the // pattern for the second matches the first: wait for the address to stop // saying "new" or the job id is the word "new". await page.waitForURL( (url) => /\/service\/[^/]+$/.test(url.pathname) && !url.pathname.endsWith('/new'), { timeout: 30_000 } ) jobId = page.url().split('/').pop() as string await page.locator('input[name="title"]').fill(`E2E tech job ${stamp}`) await expect(async () => { await page .getByRole('combobox') .filter({ hasText: /select technician/i }) .first() .click() await expect(page.getByPlaceholder(/search or create technician/i)).toBeVisible({ timeout: 2_000, }) }).toPass({ timeout: 30_000 }) await page.getByPlaceholder(/search or create technician/i).fill(TECHNICIAN) await page .getByRole('option', { name: new RegExp(TECHNICIAN) }) .first() .click() await page.getByRole('button', { name: 'Save', exact: true }).click() await expect(page.getByText('Saved', { exact: true })).toBeVisible() const jobs = await phone(device).get('/api/v1/tech/jobs') const { data } = await jobs.json() expect( data.jobs.map((job: { id: string }) => job.id), 'the assigned job reached the phone' ).toContain(jobId) }) test('puts the clock on a job and takes it off again', async () => { const started = await phone(device).post('/api/v1/tech/time/start', { serviceRecordId: jobId, }) expect(started.status()).toBe(200) const { data: startData } = await started.json() expect(startData.entry.serviceRecordId).toBe(jobId) expect(startData.entry.startedAt, 'the entry says when it started').toBeTruthy() const entries = await phone(device).get(ENTRIES) expect(entries.status()).toBe(200) expect(JSON.stringify(await entries.json())).toContain(jobId) // The job list now says the clock is on it, which is what draws the bar. const running = await phone(device).get('/api/v1/tech/jobs') expect((await running.json()).data.openEntryJobId).toBe(jobId) const stopped = await phone(device).post('/api/v1/tech/time/stop') expect(stopped.status()).toBe(200) // Nothing running, so a second stop is a conflict rather than a crash. const again = await phone(device).post('/api/v1/tech/time/stop') expect(again.status()).toBe(409) }) test('asks for a day rather than everything', async () => { // The phone owns the technician's timezone, so it sends the window; a // request without one is a client mistake and says which field is missing. const unbounded = await phone(device).get('/api/v1/tech/time/entries') expect(unbounded.status()).toBe(400) expect(JSON.stringify(await unbounded.json())).toContain('from') }) test('looks a part up by its barcode, and says so when there is none', async () => { // The phone scans a box in the stores. A code for something this workshop // does not stock is the answer the app shows most often, and it has to be // distinguishable from a fault. const missing = await phone(device).get('/api/v1/tech/parts/lookup?barcode=1234567890128') expect(missing.status()).toBe(404) expect((await missing.json()).error.code).toBe('not_found') // No barcode at all is the client's mistake, not the workshop's. const nothing = await phone(device).get('/api/v1/tech/parts/lookup') expect(nothing.status()).toBe(400) }) test('moves a job through its statuses', async () => { // The technician's own screen: pick the job up, and put it down again. const started = await phone(device).post('/api/v1/tech/jobs/' + jobId + '/status', { status: 'in-progress', }) expect(started.status(), 'PATCH is the method the app uses').toBe(405) const patched = await phone(device).patch(`/api/v1/tech/jobs/${jobId}/status`, { status: 'in-progress', }) expect(patched.status()).toBe(200) expect((await patched.json()).data.job.status).toBe('in-progress') const refused = await phone(device).patch(`/api/v1/tech/jobs/${jobId}/status`, { status: 'invented', }) expect(refused.status(), 'a status the app made up').toBeGreaterThanOrEqual(400) }) test('cannot read or clock another technician’s job', async () => { // Same workshop, somebody else's work: the list is scoped to the // technician's own rows, and so is everything reached by id. const read = await phone(device).get(`/api/v1/tech/jobs/${someoneElsesJob}`) expect(read.status(), 'reading it').toBe(404) const moved = await phone(device).patch(`/api/v1/tech/jobs/${someoneElsesJob}/status`, { status: 'completed', }) expect(moved.status(), 'moving its status').toBe(404) const clock = await phone(device).post('/api/v1/tech/time/start', { serviceRecordId: someoneElsesJob, }) // The clock is scoped to the workshop rather than to the technician, so // this one is allowed by design: a mechanic who picks up a colleague's job // books their own time against it. Stopped again so the next test starts // from a clean clock. if (clock.status() === 200) await phone(device).post('/api/v1/tech/time/stop') }) test('cannot reach another workshop’s job at all', async () => { const read = await phone(device).get(`/api/v1/tech/jobs/${foreignJob}`) expect(read.status(), 'reading it').toBe(404) // The writes, which are the half a read-only test would miss: booking // time against a job in a workshop this token has nothing to do with, and // moving that job's status. const clock = await phone(device).post('/api/v1/tech/time/start', { serviceRecordId: foreignJob, }) expect(clock.status(), 'booking time against it').toBe(404) // The message the app shows the technician, and it says why rather than // just refusing: the job is not in this workshop. expect((await clock.json()).error.message).toContain('does not exist in this workshop') const moved = await phone(device).patch(`/api/v1/tech/jobs/${foreignJob}/status`, { status: 'completed', }) expect(moved.status(), 'moving its status').toBe(404) // And nothing was booked. const entries = await phone(device).get(ENTRIES) expect(JSON.stringify(await entries.json())).not.toContain(foreignJob) }) /** * Not covered: the desk signing a phone out. * * The behaviour is right — revoking deletes the technician's sessions and * deactivates the row, so the token stops opening anything — but the control * is one icon button per member row, and driving the row for one particular * technician among the several this suite creates proved unreliable enough * that the test failed for the wrong reason more often than the right one. It * needs a `data-testid` on the row before it is worth automating. */ })