|
@@ -0,0 +1,403 @@
|
|
|
|
|
+import { expect, type APIRequestContext, type Page, test } from '@playwright/test'
|
|
|
|
|
+import { foreignServiceRecordId, organizationIdFor, 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. Signing up gives it a
|
|
|
|
|
+ // few work orders of its own, which is what makes it a useful target.
|
|
|
|
|
+ const outsider = await browser.newContext({ storageState: { cookies: [], origins: [] } })
|
|
|
|
|
+ const outsiderPage = await outsider.newPage()
|
|
|
|
|
+ await outsiderPage.goto('/auth/sign-up')
|
|
|
|
|
+ await outsiderPage.locator('#name').fill('E2E Tech Outsider')
|
|
|
|
|
+ await outsiderPage.locator('#email').fill(OUTSIDER)
|
|
|
|
|
+ await outsiderPage.locator('#password').fill(OUTSIDER_PASSWORD)
|
|
|
|
|
+ await outsiderPage.locator('#terms').click()
|
|
|
|
|
+ await outsiderPage.getByRole('button', { name: /create account/i }).click()
|
|
|
|
|
+ await outsiderPage.waitForURL(/\/onboarding/, { timeout: 30_000 })
|
|
|
|
|
+ await outsiderPage.locator('#workshopName').fill(`E2E Tech Outsider Garage ${stamp}`)
|
|
|
|
|
+ await outsiderPage.locator('form button[type="submit"]').click()
|
|
|
|
|
+ await outsiderPage.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), {
|
|
|
|
|
+ timeout: 30_000,
|
|
|
|
|
+ })
|
|
|
|
|
+ await outsider.close()
|
|
|
|
|
+
|
|
|
|
|
+ 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.
|
|
|
|
|
+ */
|
|
|
|
|
+})
|