shared-job.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import { type APIRequestContext, expect, type Locator, type Page, test } from '@playwright/test'
  2. import {
  3. deleteTechnicians,
  4. insertTechnician,
  5. jobAssignment,
  6. jobHelperIds,
  7. jobStart,
  8. ownerOrganizationId,
  9. technicianIdNamed,
  10. workshopSetting,
  11. } from '../../support/db'
  12. import { settle } from '../../support/hydration'
  13. import { jobIdOf, newWorkOrder, seededVehicleUrl } from '../../support/work-order'
  14. /**
  15. * A job worked by more than one person.
  16. *
  17. * A work order has one technician, the name on the invoice and the lane the
  18. * card sits in. A workshop also puts an apprentice or a second pair of hands
  19. * on the same car, and relies on that being written down where people look:
  20. * the work order says who leads and who is with them, the work board shows the
  21. * job in the helper's day as well as the technician's, and the helper's phone
  22. * lists it. Handing the job over swaps the two, and taking the technician off
  23. * takes everybody off, so a job never has helpers and no lead.
  24. */
  25. // Each test opens two or three of the heaviest pages (work order, work board,
  26. // team settings), which is more than the default budget on a cold server.
  27. test.describe.configure({ mode: 'serial', timeout: 120_000 })
  28. const stamp = Date.now()
  29. const LEAD = `E2E Lead ${stamp}`
  30. const HELPER = `E2E Helper ${stamp}`
  31. const MECHANIC = `E2E Phone Helper ${stamp}`
  32. const PHONE = `556${String(stamp).slice(-7)}`
  33. const TITLE = `E2E shared job ${stamp}`
  34. let organizationId = ''
  35. let leadId = ''
  36. let helperId = ''
  37. let mechanicId = ''
  38. let jobUrl = ''
  39. let jobId = ''
  40. /** The helper's phone: no cookies, so only its token speaks for it. */
  41. let device: APIRequestContext
  42. let token = ''
  43. test.beforeAll(async ({ playwright, baseURL }) => {
  44. organizationId = await ownerOrganizationId()
  45. leadId = await insertTechnician(organizationId, LEAD)
  46. helperId = await insertTechnician(organizationId, HELPER)
  47. device = await playwright.request.newContext({
  48. baseURL,
  49. storageState: { cookies: [], origins: [] },
  50. })
  51. })
  52. test.afterAll(async () => {
  53. await device?.dispose()
  54. // The job stays, like every other spec's; whoever was on it goes with the
  55. // technicians, and the helper rows with them.
  56. await deleteTechnicians([leadId, helperId].filter(Boolean))
  57. })
  58. /** The card's technician list. */
  59. function technicianList(page: Page): Locator {
  60. return page.getByRole('radiogroup', { name: 'Technician' })
  61. }
  62. function rowOf(page: Page, technicianId: string): Locator {
  63. return page.getByTestId(`tech-row-${technicianId}`)
  64. }
  65. /** Makes somebody the job's technician with a click on their name. */
  66. async function makeTechnician(page: Page, technicianId: string): Promise<void> {
  67. const radio = rowOf(page, technicianId).getByRole('radio')
  68. await expect(async () => {
  69. await radio.click()
  70. await expect(radio).toHaveAttribute('aria-checked', 'true', { timeout: 2_000 })
  71. }).toPass({ timeout: 30_000 })
  72. await expect.poll(async () => (await jobAssignment(jobId)).technicianId).toBe(technicianId)
  73. }
  74. /** Puts somebody on the job beside its technician, from the end of their row. */
  75. async function addHelper(page: Page, name: string, technicianId: string): Promise<void> {
  76. const add = page.getByRole('button', { name: `Add ${name} to this job` })
  77. await expect(add).toBeVisible()
  78. await add.click()
  79. await expect(page.getByRole('button', { name: `Take ${name} off this job` })).toHaveAttribute(
  80. 'aria-pressed',
  81. 'true'
  82. )
  83. await expect.poll(() => jobHelperIds(jobId)).toContain(technicianId)
  84. }
  85. /** The day a job starts on, and the Monday-or-whatever its board week starts on. */
  86. async function boardAddressFor(serviceRecordId: string): Promise<string> {
  87. const start = await jobStart(serviceRecordId)
  88. expect(start, 'a new job is booked into a slot').not.toBeNull()
  89. const zone =
  90. (await workshopSetting(organizationId, 'workshop.timezone')) ||
  91. (await workshopSetting(organizationId, 'workshop.timezoneDetected')) ||
  92. 'UTC'
  93. // The board's days are the workshop's days, not UTC's or the browser's.
  94. const day = new Intl.DateTimeFormat('en-CA', {
  95. timeZone: zone,
  96. year: 'numeric',
  97. month: '2-digit',
  98. day: '2-digit',
  99. }).format(start as Date)
  100. const weekStartDay = Number(
  101. (await workshopSetting(organizationId, 'workboard.weekStartDay')) ?? '1'
  102. )
  103. const noon = Date.parse(`${day}T12:00:00Z`)
  104. const back = (new Date(noon).getUTCDay() - weekStartDay + 7) % 7
  105. const week = new Date(noon - back * 86_400_000).toISOString().slice(0, 10)
  106. return `/work-board?view=week&week=${week}&date=${day}&group=technician`
  107. }
  108. /** Whether a block sits inside one of the columns headed with this name. */
  109. async function inColumnOf(page: Page, block: Locator, name: string): Promise<boolean> {
  110. const box = await block.boundingBox()
  111. if (!box) return false
  112. // Every day repeats the lane headers, so the column is one of several.
  113. const headers = page.getByRole('button', { name, exact: true })
  114. for (const header of await headers.all()) {
  115. const lane = await header.boundingBox()
  116. if (lane && box.x >= lane.x - 2 && box.x + box.width <= lane.x + lane.width + 2) return true
  117. }
  118. return false
  119. }
  120. test('a helper is put on the job beside its technician', async ({ page }) => {
  121. jobUrl = await newWorkOrder(page, await seededVehicleUrl(page), TITLE)
  122. jobId = jobIdOf(jobUrl)
  123. await expect(technicianList(page)).toBeVisible()
  124. // Nobody can help on a job nobody has: no add buttons yet.
  125. await expect(page.getByRole('button', { name: `Add ${HELPER} to this job` })).toHaveCount(0)
  126. await makeTechnician(page, leadId)
  127. await addHelper(page, HELPER, helperId)
  128. await expect(rowOf(page, helperId)).toContainText('Also on this job')
  129. await expect(rowOf(page, leadId)).toContainText('Lead')
  130. await expect(rowOf(page, leadId)).not.toContainText('Also on this job')
  131. // Written down, not just drawn.
  132. await page.reload()
  133. await settle(page)
  134. await expect(rowOf(page, helperId)).toContainText('Also on this job')
  135. await expect(rowOf(page, leadId)).toContainText('Lead')
  136. await expect(rowOf(page, leadId).getByRole('radio')).toHaveAttribute('aria-checked', 'true')
  137. expect((await jobAssignment(jobId)).technicianId).toBe(leadId)
  138. expect(await jobHelperIds(jobId)).toEqual([helperId])
  139. })
  140. test('the board shows the job in the helper’s day, saying whose it is', async ({ page }) => {
  141. await page.goto(await boardAddressFor(jobId))
  142. await settle(page)
  143. // The job itself: a solid block in the technician's column, counting the
  144. // one extra person on it.
  145. const block = page.locator(
  146. `[role="button"][aria-label*="${TITLE}"]:not([data-testid="job-shadow"])`
  147. )
  148. await expect(block).toHaveCount(1)
  149. await expect(block.getByTestId('job-team-count')).toHaveText('+1')
  150. await block.scrollIntoViewIfNeeded()
  151. expect(await inColumnOf(page, block, LEAD), 'the job sits in its technician’s column').toBe(true)
  152. // Its echo: an outline in the helper's column naming the technician.
  153. const shadow = page.getByTestId('job-shadow').filter({ hasText: TITLE })
  154. await expect(shadow).toHaveCount(1)
  155. await expect(shadow).toContainText(`With ${LEAD}`)
  156. await shadow.scrollIntoViewIfNeeded()
  157. expect(await inColumnOf(page, shadow, HELPER), 'the outline sits in the helper’s column').toBe(
  158. true
  159. )
  160. // Opened from the board, the job says who else is on it.
  161. await expect(async () => {
  162. await block.click()
  163. await expect(page.getByRole('dialog', { name: TITLE })).toBeVisible({ timeout: 2_000 })
  164. }).toPass({ timeout: 30_000 })
  165. const dialog = page.getByRole('dialog', { name: TITLE })
  166. await expect(dialog.getByText('Also on this job')).toBeVisible()
  167. await expect(dialog.getByRole('button', { name: HELPER, exact: true })).toHaveAttribute(
  168. 'aria-pressed',
  169. 'true'
  170. )
  171. })
  172. test('making the helper the technician swaps the two', async ({ page }) => {
  173. await page.goto(jobUrl)
  174. await settle(page)
  175. await makeTechnician(page, helperId)
  176. await expect(rowOf(page, helperId)).toContainText('Lead')
  177. await expect(rowOf(page, helperId)).not.toContainText('Also on this job')
  178. await expect(rowOf(page, leadId)).toContainText('Also on this job')
  179. await expect(page.getByRole('button', { name: `Take ${LEAD} off this job` })).toHaveAttribute(
  180. 'aria-pressed',
  181. 'true'
  182. )
  183. await expect.poll(() => jobHelperIds(jobId)).toEqual([leadId])
  184. expect((await jobAssignment(jobId)).technicianId).toBe(helperId)
  185. })
  186. test('no technician takes everybody off the job', async ({ page }) => {
  187. await page.goto(jobUrl)
  188. await settle(page)
  189. await expect(async () => {
  190. await page.getByRole('button', { name: 'Someone else' }).click()
  191. await expect(page.getByRole('option', { name: 'No technician' })).toBeVisible({
  192. timeout: 2_000,
  193. })
  194. }).toPass({ timeout: 30_000 })
  195. await page.getByRole('option', { name: 'No technician' }).click()
  196. await expect.poll(async () => (await jobAssignment(jobId)).technicianId).toBeNull()
  197. await expect.poll(() => jobHelperIds(jobId)).toEqual([])
  198. const list = technicianList(page)
  199. await expect(list.getByRole('radio', { checked: true })).toHaveCount(0)
  200. await expect(list.getByText('Also on this job')).toHaveCount(0)
  201. await expect(list.getByText('Lead', { exact: true })).toHaveCount(0)
  202. await expect(list.getByRole('button', { name: /to this job$|off this job$/ })).toHaveCount(0)
  203. await page.reload()
  204. await settle(page)
  205. await expect(technicianList(page).getByText('Also on this job')).toHaveCount(0)
  206. })
  207. test.describe('the helper’s phone', () => {
  208. test('a mechanic with the app is set up at the desk', async ({ page }) => {
  209. // The same path as specs/tech/api.spec.ts: a person with a phone, a code
  210. // read off the desk, the code redeemed by a context with no cookies.
  211. await page.goto('/settings/team')
  212. await settle(page)
  213. await expect(async () => {
  214. await page.getByRole('button', { name: 'Add', exact: true }).first().click()
  215. await expect(page.getByText('A mechanic')).toBeVisible({ timeout: 2_000 })
  216. }).toPass({ timeout: 30_000 })
  217. await page.getByText('A mechanic').click()
  218. await expect(page.getByPlaceholder('Their full name')).toBeVisible({ timeout: 10_000 })
  219. await page.getByPlaceholder('Their full name').fill(MECHANIC)
  220. const country = page
  221. .getByRole('combobox')
  222. .filter({ hasText: /choose a country/i })
  223. .first()
  224. if (await country.isVisible().catch(() => false)) {
  225. await country.click()
  226. await page
  227. .getByRole('option', { name: /United States/i })
  228. .first()
  229. .click()
  230. }
  231. await page.getByPlaceholder('The phone in their pocket').fill(PHONE)
  232. await page.getByRole('button', { name: 'Create', exact: true }).click()
  233. await expect(page.getByText(/or read them this code/i)).toBeVisible({ timeout: 30_000 })
  234. const codeText = await page
  235. .getByText(/^[ABCDEFGHJKLMNPQRTUVWXYZ2346789]{4}[\s-]?[ABCDEFGHJKLMNPQRTUVWXYZ2346789]{4}$/)
  236. .first()
  237. .innerText()
  238. const code = codeText.replace(/[^A-Z2-9]/g, '')
  239. expect(code).toHaveLength(8)
  240. const redeemed = await device.post('/api/v1/tech/setup/redeem', { data: { code } })
  241. expect(redeemed.status()).toBe(200)
  242. token = (await redeemed.json()).data.token
  243. expect(token).toBeTruthy()
  244. mechanicId = await technicianIdNamed(organizationId, MECHANIC)
  245. })
  246. test('lists a job they only help on, and drops it when taken off', async ({ page }) => {
  247. const jobsOnPhone = async (): Promise<string[]> => {
  248. const response = await device.get('/api/v1/tech/jobs', {
  249. headers: { authorization: `Bearer ${token}` },
  250. })
  251. expect(response.status()).toBe(200)
  252. return (await response.json()).data.jobs.map((job: { id: string }) => job.id)
  253. }
  254. expect(await jobsOnPhone(), 'nothing of theirs yet').not.toContain(jobId)
  255. await page.goto(jobUrl)
  256. await settle(page)
  257. await makeTechnician(page, leadId)
  258. await addHelper(page, MECHANIC, mechanicId)
  259. expect((await jobAssignment(jobId)).technicianId, 'still not their job to lead').toBe(leadId)
  260. await expect.poll(jobsOnPhone, { message: 'the helper’s phone lists the job' }).toContain(jobId)
  261. const takeOff = page.getByRole('button', { name: `Take ${MECHANIC} off this job` })
  262. await takeOff.click()
  263. await expect(page.getByRole('button', { name: `Add ${MECHANIC} to this job` })).toBeVisible()
  264. await expect.poll(() => jobHelperIds(jobId)).toEqual([])
  265. await expect.poll(jobsOnPhone, { message: 'and drops it again' }).not.toContain(jobId)
  266. })
  267. })