api.spec.ts 17 KB

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