api.spec.ts 17 KB

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