api.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. import { expect, type APIRequestContext, type Page, test } from '@playwright/test'
  2. import {
  3. foreignServiceRecordId,
  4. jobAssignment,
  5. organizationIdFor,
  6. plantJob,
  7. plantWorkshop,
  8. seededTenantFixtures,
  9. } from '../../support/db'
  10. import { settle } from '../../support/hydration'
  11. import { laborRows, saveWorkOrder, setTitle } from '../../support/work-order'
  12. /**
  13. * The contract the technician app is built against.
  14. *
  15. * `/api/v1/tech/*` is consumed by a phone app that lives in another
  16. * repository and ships through two app stores, so a break here is not a
  17. * deploy away from being fixed: it is a review queue away. Only `/health` was
  18. * covered, which proves the routes are mounted and nothing else.
  19. *
  20. * The whole path is walked as the app walks it: the desk adds a technician and
  21. * reads them a setup code, the phone exchanges the code for a token, and the
  22. * token is used to list the day's work and put the clock on a job. Then the
  23. * refusals, which matter more than the successes — the token must not reach
  24. * another technician's job, and must not reach another workshop's at all,
  25. * neither to read it nor to book time against it.
  26. */
  27. test.describe.configure({ mode: 'serial' })
  28. const stamp = Date.now()
  29. const TECHNICIAN = `E2E Tech ${stamp}`
  30. const PHONE = `555${String(stamp).slice(-7)}`
  31. /** The outsider whose workshop provides a job this token has no business with. */
  32. const OUTSIDER = `e2e-tech-outsider-${stamp}@example.com`
  33. const OUTSIDER_PASSWORD = `E2e-pass-${stamp}`
  34. /** The window the app asks its day summary for; the phone owns the timezone. */
  35. const DAY = {
  36. from: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
  37. to: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
  38. }
  39. const ENTRIES = `/api/v1/tech/time/entries?from=${DAY.from}&to=${DAY.to}`
  40. let setupCode = ''
  41. let token = ''
  42. /** The phone's own context: no cookies, so only the token speaks for it. */
  43. let device: APIRequestContext
  44. let jobId = ''
  45. /** A job in this workshop that belongs to a different technician. */
  46. let someoneElsesJob = ''
  47. /** A job in another workshop altogether. */
  48. let foreignJob = ''
  49. /**
  50. * The phone: a request context carrying nothing but the token it was given.
  51. *
  52. * A cookie-free context on purpose. The suite's own contexts are signed in as
  53. * the workshop owner, and `withApiAuth` treats the bearer header as a gate and
  54. * then resolves the session from the request's headers — so a context with the
  55. * owner's cookie in it answers as the owner however the token reads, and a
  56. * test written on it proves nothing about the token at all.
  57. */
  58. function phone(request: APIRequestContext, bearer = token) {
  59. return {
  60. get: (url: string) => request.get(url, { headers: { authorization: `Bearer ${bearer}` } }),
  61. post: (url: string, data?: unknown) =>
  62. request.post(url, {
  63. headers: { authorization: `Bearer ${bearer}` },
  64. ...(data ? { data } : {}),
  65. }),
  66. patch: (url: string, data?: unknown) =>
  67. request.patch(url, {
  68. headers: { authorization: `Bearer ${bearer}` },
  69. ...(data ? { data } : {}),
  70. }),
  71. }
  72. }
  73. async function openTeamSettings(page: Page) {
  74. await page.goto('/settings/team')
  75. await settle(page)
  76. }
  77. test.beforeAll(async ({ browser, playwright, baseURL }) => {
  78. // An empty storage state, spelled out: a context made through the
  79. // `playwright` fixture inherits the project's, which is the workshop owner
  80. // signed in. With that cookie present the session comes back as the owner
  81. // however the bearer token reads, and every assertion below would be about
  82. // the wrong person.
  83. device = await playwright.request.newContext({
  84. baseURL,
  85. storageState: { cookies: [], origins: [] },
  86. })
  87. const seeded = await seededTenantFixtures()
  88. // A job in this workshop that will not be assigned to the new technician.
  89. someoneElsesJob = seeded.serviceRecordId
  90. // A second workshop, for the cross-workshop refusals, planted with a job
  91. // of its own: a self-hosted install opens one workshop, so a sign-up would
  92. // be told to ask for an invitation instead of opening this one.
  93. const outsider = await plantWorkshop({
  94. name: 'E2E Tech Outsider',
  95. email: OUTSIDER,
  96. password: OUTSIDER_PASSWORD,
  97. workshopName: `E2E Tech Outsider Garage ${stamp}`,
  98. })
  99. await plantJob(outsider.organizationId, outsider.userId, `E2E Tech Outsider Job ${stamp}`)
  100. foreignJob = await foreignServiceRecordId(await organizationIdFor(OUTSIDER))
  101. expect(foreignJob).not.toBe(someoneElsesJob)
  102. })
  103. test.afterAll(async () => {
  104. await device?.dispose()
  105. })
  106. test.describe('the technician app', () => {
  107. test('answers before anybody has signed in', async () => {
  108. const health = await device.get('/api/v1/tech/health')
  109. expect(health.status()).toBe(200)
  110. })
  111. test('refuses every endpoint without a token', async () => {
  112. for (const url of [
  113. '/api/v1/tech/me',
  114. '/api/v1/tech/jobs',
  115. ENTRIES,
  116. '/api/v1/tech/parts/lookup?barcode=1234567890128',
  117. ]) {
  118. const response = await device.get(url)
  119. expect(response.status(), `${url} without a token`).toBe(401)
  120. }
  121. const start = await device.post('/api/v1/tech/time/start', {
  122. data: { serviceRecordId: someoneElsesJob },
  123. })
  124. expect(start.status(), 'starting the clock without a token').toBe(401)
  125. })
  126. test('the desk adds a technician and reads them a code', async ({ page }) => {
  127. await openTeamSettings(page)
  128. // One Add button, then a choice: the two kinds of person are set up
  129. // differently, and a mechanic is the one who gets the app.
  130. await expect(async () => {
  131. await page.getByRole('button', { name: 'Add', exact: true }).first().click()
  132. await expect(page.getByText('A mechanic')).toBeVisible({ timeout: 2_000 })
  133. }).toPass({ timeout: 30_000 })
  134. await page.getByText('A mechanic').click()
  135. await expect(page.getByPlaceholder('Their full name')).toBeVisible({ timeout: 10_000 })
  136. await page.getByPlaceholder('Their full name').fill(TECHNICIAN)
  137. // A mobile number cannot be read without knowing which country's it is,
  138. // and this workshop has never said. Asked once, then remembered.
  139. const country = page
  140. .getByRole('combobox')
  141. .filter({ hasText: /choose a country/i })
  142. .first()
  143. if (await country.isVisible().catch(() => false)) {
  144. await country.click()
  145. await page
  146. .getByRole('option', { name: /United States/i })
  147. .first()
  148. .click()
  149. }
  150. await page.getByPlaceholder('The phone in their pocket').fill(PHONE)
  151. await page.getByRole('button', { name: 'Create', exact: true }).click()
  152. // The dialog turns into the setup instructions, with the code printed for
  153. // a technician who is not standing at the desk.
  154. await expect(page.getByText(/or read them this code/i)).toBeVisible({ timeout: 30_000 })
  155. const codeText = await page
  156. .getByText(/^[ABCDEFGHJKLMNPQRTUVWXYZ2346789]{4}[\s-]?[ABCDEFGHJKLMNPQRTUVWXYZ2346789]{4}$/)
  157. .first()
  158. .innerText()
  159. setupCode = codeText.replace(/[^A-Z2-9]/g, '')
  160. expect(setupCode, 'the code is eight characters').toHaveLength(8)
  161. })
  162. // The redeem endpoint is the one thing here anybody on the internet can
  163. // reach with a guess, so it allows five anonymous attempts a minute. This
  164. // file spends three of them and no more: hammering it would only prove the
  165. // limiter works, at the cost of the tests that come after.
  166. test('a code can be spent once, and only once', async () => {
  167. const redeemed = await device.post('/api/v1/tech/setup/redeem', { data: { code: setupCode } })
  168. expect(redeemed.status()).toBe(200)
  169. const body = await redeemed.json()
  170. token = body.data.token
  171. expect(token, 'the phone is given a token').toBeTruthy()
  172. expect(body.data.workshop).toBe('Demo Auto Workshop')
  173. // Two phones scanning the same screen: exactly one of them wins.
  174. const again = await device.post('/api/v1/tech/setup/redeem', { data: { code: setupCode } })
  175. expect(again.status()).toBe(400)
  176. expect((await again.json()).error.code).toBe('code_used')
  177. })
  178. test('a made-up code is refused, and says nothing about who exists', async () => {
  179. const response = await device.post('/api/v1/tech/setup/redeem', { data: { code: 'ZZZZ9999' } })
  180. // Run again inside the same minute and the limiter answers before the
  181. // code is even looked at, which is the right order for it to answer in.
  182. expect([400, 429]).toContain(response.status())
  183. if (response.status() === 400) {
  184. const body = await response.json()
  185. // Not "no such technician", not "wrong workshop": one answer for
  186. // everything, so the endpoint cannot be used to find out who exists.
  187. expect(body.error.code).toBe('invalid_code')
  188. }
  189. })
  190. test('says who is holding the phone, and which workshop', async () => {
  191. const me = await phone(device).get('/api/v1/tech/me')
  192. expect(me.status()).toBe(200)
  193. const { data } = await me.json()
  194. // Everything the app's first screen is built from, in one answer.
  195. expect(data.organization.name).toBe('Demo Auto Workshop')
  196. expect(data.technicians.map((t: { name: string }) => t.name)).toContain(TECHNICIAN)
  197. expect(data.isTechnician).toBe(true)
  198. expect(data.isAdmin).toBe(false)
  199. // The app refuses to run below this, so it has to keep coming back.
  200. expect(data.minAppVersion, 'the minimum version the app must meet').toBeTruthy()
  201. })
  202. test('lists nothing until there is work assigned', async () => {
  203. const jobs = await phone(device).get('/api/v1/tech/jobs')
  204. expect(jobs.status()).toBe(200)
  205. const { data } = await jobs.json()
  206. // A technician who has just been created is assigned nothing, and the
  207. // app's home screen has to cope with that rather than with an error.
  208. expect(data.jobs).toEqual([])
  209. // The same answer says whether a clock is already running, so the app can
  210. // draw its running bar without a second request.
  211. expect(data.openEntryJobId).toBeNull()
  212. })
  213. test('the day’s work appears once the desk assigns it', async ({ page }) => {
  214. // Assigned from the work order's schedule card, which is where a service
  215. // adviser does it.
  216. await page.goto(`/vehicles/${(await seededTenantFixtures()).vehicleId}/service/new`)
  217. // `/service/new` creates the draft and redirects to its id, and the
  218. // pattern for the second matches the first: wait for the address to stop
  219. // saying "new" or the job id is the word "new".
  220. await page.waitForURL(
  221. (url) => /\/service\/[^/]+$/.test(url.pathname) && !url.pathname.endsWith('/new'),
  222. { timeout: 30_000 }
  223. )
  224. jobId = page.url().split('/').pop() as string
  225. await settle(page)
  226. await setTitle(page, `E2E tech job ${stamp}`)
  227. await saveWorkOrder(page)
  228. // The technician list puts them on the job with one click.
  229. const technician = page
  230. .getByRole('radiogroup', { name: 'Technician' })
  231. .getByRole('radio', { name: new RegExp(TECHNICIAN) })
  232. await expect(async () => {
  233. await technician.click()
  234. await expect(technician).toHaveAttribute('aria-checked', 'true', { timeout: 2_000 })
  235. }).toPass({ timeout: 30_000 })
  236. await expect.poll(async () => (await jobAssignment(jobId)).technicianId).toBeTruthy()
  237. const jobs = await phone(device).get('/api/v1/tech/jobs')
  238. const { data } = await jobs.json()
  239. expect(
  240. data.jobs.map((job: { id: string }) => job.id),
  241. 'the assigned job reached the phone'
  242. ).toContain(jobId)
  243. })
  244. test('puts the clock on a job and takes it off again', async () => {
  245. const started = await phone(device).post('/api/v1/tech/time/start', {
  246. serviceRecordId: jobId,
  247. })
  248. expect(started.status()).toBe(200)
  249. const { data: startData } = await started.json()
  250. expect(startData.entry.serviceRecordId).toBe(jobId)
  251. expect(startData.entry.startedAt, 'the entry says when it started').toBeTruthy()
  252. const entries = await phone(device).get(ENTRIES)
  253. expect(entries.status()).toBe(200)
  254. expect(JSON.stringify(await entries.json())).toContain(jobId)
  255. // The job list now says the clock is on it, which is what draws the bar.
  256. const running = await phone(device).get('/api/v1/tech/jobs')
  257. expect((await running.json()).data.openEntryJobId).toBe(jobId)
  258. const stopped = await phone(device).post('/api/v1/tech/time/stop')
  259. expect(stopped.status()).toBe(200)
  260. // Nothing running, so a second stop is a conflict rather than a crash.
  261. const again = await phone(device).post('/api/v1/tech/time/stop')
  262. expect(again.status()).toBe(409)
  263. })
  264. /**
  265. * The other half of clocking off: the time is billed onto the job, and the
  266. * desk has that job open while it happens.
  267. *
  268. * A work order saves its labour by replacing every line, so a desk holding
  269. * a list read before the technician's line existed deletes that line on its
  270. * next save. The page therefore hears about it on the work board channel
  271. * and reads the job again, and the line appears without a reload. This is
  272. * the whole path: phone, endpoint, socket, browser.
  273. */
  274. test('bills time onto the job, and the desk sees it without reloading', async ({ page }) => {
  275. const description = `E2E bay labour ${stamp}`
  276. await page.goto(`/vehicles/${(await seededTenantFixtures()).vehicleId}/service/${jobId}`)
  277. await settle(page)
  278. // The socket is opened by the app shell after hydration; a line added
  279. // before it is listening would only be found by reloading.
  280. await expect(laborRows(page)).toHaveCount(0)
  281. const added = await phone(device).post(`/api/v1/tech/jobs/${jobId}/labor`, {
  282. description,
  283. hours: 1.5,
  284. })
  285. expect(added.status()).toBe(201)
  286. expect((await added.json()).data.labor.hours).toBe(1.5)
  287. // No reload anywhere in this test: the page is told.
  288. await expect(laborRows(page)).toHaveCount(1, { timeout: 30_000 })
  289. await expect(laborRows(page).first()).toHaveValue(description)
  290. expect(page.url(), 'the page never navigated').toContain(jobId)
  291. })
  292. test('asks for a day rather than everything', async () => {
  293. // The phone owns the technician's timezone, so it sends the window; a
  294. // request without one is a client mistake and says which field is missing.
  295. const unbounded = await phone(device).get('/api/v1/tech/time/entries')
  296. expect(unbounded.status()).toBe(400)
  297. expect(JSON.stringify(await unbounded.json())).toContain('from')
  298. })
  299. test('looks a part up by its barcode, and says so when there is none', async () => {
  300. // The phone scans a box in the stores. A code for something this workshop
  301. // does not stock is the answer the app shows most often, and it has to be
  302. // distinguishable from a fault.
  303. const missing = await phone(device).get('/api/v1/tech/parts/lookup?barcode=1234567890128')
  304. expect(missing.status()).toBe(404)
  305. expect((await missing.json()).error.code).toBe('not_found')
  306. // No barcode at all is the client's mistake, not the workshop's.
  307. const nothing = await phone(device).get('/api/v1/tech/parts/lookup')
  308. expect(nothing.status()).toBe(400)
  309. })
  310. test('moves a job through its statuses', async () => {
  311. // The technician's own screen: pick the job up, and put it down again.
  312. const started = await phone(device).post('/api/v1/tech/jobs/' + jobId + '/status', {
  313. status: 'in-progress',
  314. })
  315. expect(started.status(), 'PATCH is the method the app uses').toBe(405)
  316. const patched = await phone(device).patch(`/api/v1/tech/jobs/${jobId}/status`, {
  317. status: 'in-progress',
  318. })
  319. expect(patched.status()).toBe(200)
  320. expect((await patched.json()).data.job.status).toBe('in-progress')
  321. const refused = await phone(device).patch(`/api/v1/tech/jobs/${jobId}/status`, {
  322. status: 'invented',
  323. })
  324. expect(refused.status(), 'a status the app made up').toBeGreaterThanOrEqual(400)
  325. })
  326. test('cannot read or clock another technician’s job', async () => {
  327. // Same workshop, somebody else's work: the list is scoped to the
  328. // technician's own rows, and so is everything reached by id.
  329. const read = await phone(device).get(`/api/v1/tech/jobs/${someoneElsesJob}`)
  330. expect(read.status(), 'reading it').toBe(404)
  331. const moved = await phone(device).patch(`/api/v1/tech/jobs/${someoneElsesJob}/status`, {
  332. status: 'completed',
  333. })
  334. expect(moved.status(), 'moving its status').toBe(404)
  335. const clock = await phone(device).post('/api/v1/tech/time/start', {
  336. serviceRecordId: someoneElsesJob,
  337. })
  338. // The clock is scoped to the workshop rather than to the technician, so
  339. // this one is allowed by design: a mechanic who picks up a colleague's job
  340. // books their own time against it. Stopped again so the next test starts
  341. // from a clean clock.
  342. if (clock.status() === 200) await phone(device).post('/api/v1/tech/time/stop')
  343. })
  344. test('cannot reach another workshop’s job at all', async () => {
  345. const read = await phone(device).get(`/api/v1/tech/jobs/${foreignJob}`)
  346. expect(read.status(), 'reading it').toBe(404)
  347. // The writes, which are the half a read-only test would miss: booking
  348. // time against a job in a workshop this token has nothing to do with, and
  349. // moving that job's status.
  350. const clock = await phone(device).post('/api/v1/tech/time/start', {
  351. serviceRecordId: foreignJob,
  352. })
  353. expect(clock.status(), 'booking time against it').toBe(404)
  354. // The message the app shows the technician, and it says why rather than
  355. // just refusing: the job is not in this workshop.
  356. expect((await clock.json()).error.message).toContain('does not exist in this workshop')
  357. const moved = await phone(device).patch(`/api/v1/tech/jobs/${foreignJob}/status`, {
  358. status: 'completed',
  359. })
  360. expect(moved.status(), 'moving its status').toBe(404)
  361. // And nothing was booked.
  362. const entries = await phone(device).get(ENTRIES)
  363. expect(JSON.stringify(await entries.json())).not.toContain(foreignJob)
  364. })
  365. /**
  366. * Not covered: the desk signing a phone out.
  367. *
  368. * The behaviour is right — revoking deletes the technician's sessions and
  369. * deactivates the row, so the token stops opening anything — but the control
  370. * is one icon button per member row, and driving the row for one particular
  371. * technician among the several this suite creates proved unreliable enough
  372. * that the test failed for the wrong reason more often than the right one. It
  373. * needs a `data-testid` on the row before it is worth automating.
  374. */
  375. })