Skip to content

Commit d774579

Browse files
committed
Close the four gaps that could be done without the lab
A DOM for the Vue suites, a multi-line box, better-auth's own client, and service images that carry no build tooling. Each has an ADR and each was verified against the running system rather than in a diff. jsdom, pinned in the catalog, and the admin app's privileged controls tested as sequences rather than as states. One click never turns card access on, a card is assigned only after the question naming the member is answered, the roles form sends only what changed and never card access, and the rear unlock button and every stale door control send nothing. Each was watched failing against a deliberately broken component. 28 new tests in the admin app. Field takes rows, and above one the box is a text area. The two skills questions on the members profile use it. Typed into a browser, saved, and read back out of Postgres with the line break intact. The members app builds better-auth's client with createAuthClient rather than posting to four paths copied out of its published sources. Sign in, a refused sign in, sign out and the whole reset loop including the emailed link were walked in a browser against the running stack. Both services bundle to one file per entry point with esbuild, and neither runtime image has a node_modules. The API image is 240 MB rather than 475, of which 231 MB is the node:24.20-alpine base. The door image is 232 MB rather than 271. Every entry point in the API image was run: migrate, seed, make-admin, and the server signing a seeded member in. pnpm deploy --prod does not prune a workspace dependency's devDependencies, and the two pnpm-shaped fixes were tried and measured before this one; ADR 0013 records both. Also: password-reset.test.ts failed rather than skipping without DATABASE_URL, because Vitest runs a skipped describe's body to collect the names in it. It uses the harness's testConfig now, so pnpm check passes with no database, the way README.md says it does. 631 tests. Lint, typecheck and the voice check are clean.
1 parent 1c4876e commit d774579

46 files changed

Lines changed: 1438 additions & 185 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ATTRIBUTIONS.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ the lockfile changes.
2121

2222
## Direct dependencies
2323

24-
The 24 packages named in a `package.json` in this workspace. Versions are the
24+
The 28 packages named in a `package.json` in this workspace. Versions are the
2525
ones installed, and every shared version is pinned once in the
2626
`pnpm-workspace.yaml` catalog.
2727

@@ -35,14 +35,18 @@ ones installed, and every shared version is pinned once in the
3535
| `@vitejs/plugin-vue` | 6.0.8 | MIT | Single file component compilation |
3636
| `vite` | 8.2.2 | MIT | Dev server and app builds |
3737
| `vitest` | 4.1.11 | MIT | Test runner everywhere |
38+
| `jsdom` | 30.0.1 | MIT | The DOM the Vue suites click in, per ADR 0011 |
39+
| `esbuild` | 0.28.2 | MIT | Bundles each service to one file for its image, per ADR 0013 |
3840
| `hono` | 4.13.5 | MIT | HTTP framework for the API and the door service |
3941
| `@hono/node-server` | 2.1.1 | MIT | Node adapter under Hono |
4042
| `@hono/zod-validator` | 0.9.1 | MIT | Validates requests against the shared schemas |
4143
| `zod` | 4.5.4 | MIT | Request and response schemas in `packages/schema` |
42-
| `better-auth` | 1.7.2 | MIT | Sessions and accounts inside the API service |
44+
| `better-auth` | 1.7.2 | MIT | Sessions and accounts inside the API service, and its own client in the members app, per ADR 0012 |
4345
| `bcryptjs` | 3.0.2 | BSD-3-Clause | Verifies the legacy password hashes, per ADR 0004 |
4446
| `drizzle-orm` | 0.45.2 | Apache-2.0 | Tables and queries |
4547
| `drizzle-kit` | 0.31.10 | MIT | Generates the migrations, per ADR 0006 |
48+
| `nodemailer` | 9.1.1 | MIT | Sends the password reset mail from the API |
49+
| `@types/nodemailer` | 8.0.1 | MIT | Types for the mailer |
4650
| `pg` | 8.23.0 | MIT | Postgres driver. Also the only dependency of `tools/import`, which resolves it from `services/api` |
4751
| `@types/pg` | 8.15.6 | MIT | Types for the driver |
4852
| `typescript` | 6.0.3 | Apache-2.0 | The language, pinned by ADR 0009 |

HANDOFF.md

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,25 @@ runs on a laptop under Docker Compose, and `README.md` is the instructions.
1313
| Part | State | Proven by |
1414
|---|---|---|
1515
| `packages/schema` | built | 26 tests, 3 migrations applied to a real Postgres |
16-
| `packages/ui` | built | 24 tests, rendered in a browser in both themes |
16+
| `packages/ui` | built | 28 tests, rendered in a browser in both themes |
1717
| `packages/api-client` | built | 14 tests |
1818
| `services/api` | built | 198 tests against a real Postgres |
1919
| `services/door` | built, never spoken to hardware | 125 tests against a fake controller |
20-
| `apps/members` | built | 44 tests, walked through in a browser |
20+
| `apps/members` | built | 50 tests, walked through in a browser |
2121
| `apps/signup` | built | 33 tests, walked through in a browser |
22-
| `apps/admin` | built | 129 tests, walked through in a browser |
22+
| `apps/admin` | built | 157 tests, walked through in a browser |
2323
| `tools/import` | built, run against the real dump | 23 tests, plus the run in section 2 |
2424
| Compose stack | runs | brought up from nothing, every URL answers |
2525
| Backup and restore | works | `tools/restore-drill.sh` passes, in CI |
2626
| Deployment | not started | no host exists yet, see section 5 |
2727

28-
593 tests. Lint, typecheck and the voice check are clean.
28+
631 tests. Lint, typecheck and the voice check are clean.
2929

30-
14,668 lines of TypeScript and Vue, 7,334 lines of tests, 22 routes, 10 ADRs.
31-
The previous attempt was 50,941 lines and deployed nothing; the difference is
32-
almost entirely enforcement machinery that is not here on purpose.
30+
14,402 lines of TypeScript, Vue and build scripts, and 9,186 lines of tests,
31+
fixtures and harnesses, counted across `apps`, `packages`, `services` and
32+
`tools` with build output excluded. 22 routes, 13 ADRs. The previous attempt was
33+
50,941 lines and deployed nothing; the difference is almost entirely enforcement
34+
machinery that is not here on purpose.
3335

3436
## 2. What has been proven against real data
3537

@@ -179,18 +181,39 @@ Beyond section 3. None of these is hidden in the code.
179181

180182
- The door service has never spoken to real hardware. Every test runs against a
181183
fake that speaks the same wire protocol through the same codec.
182-
- The API image is 487 MB because `pnpm deploy --prod` keeps a workspace
183-
dependency's own devDependencies. Roughly 110 MB of build tooling ships and
184-
never runs.
185-
- The members app posts to the three better-auth endpoints directly rather than
186-
using `better-auth/vue`, which is not a dependency of that app. The call sites
187-
name the file and line each path was read from.
188-
- `packages/ui` has no multi-line input, so the two free text profile fields use
189-
single line ones.
190-
- The admin suites render with `renderToString` and cannot click, because no DOM
191-
environment is installed. Interaction is covered by rendering each state and by
192-
pure functions.
193184
- No deploy workflow. Deployment is four lines by hand in `docs/operations.md`.
185+
- A stack trace from either service points into a bundled file rather than into
186+
a source file. `docs/decisions/0013-services-ship-as-a-bundle.md` says why,
187+
and rebuilding the same commit gives the same line numbers.
188+
189+
Closed since the audit. These are the four this section used to list as doable
190+
now, each with an ADR and each verified against the running system rather than
191+
in a diff.
192+
193+
- **jsdom, and the admin app's interactions.** The privileged controls are
194+
tested as sequences, not as states: one click never turns card access on, a
195+
card is assigned only after the question naming the member is answered, the
196+
roles form sends only what changed and never card access, and the rear unlock
197+
button and every stale control send nothing. Each of those was watched failing
198+
against a deliberately broken component. ADR 0011.
199+
- **A multi-line box.** `Field` takes `rows`, and the two skills questions on
200+
the profile use it. Typed into a browser, saved, and read back out of Postgres
201+
with the line break intact.
202+
- **better-auth's own client** in the members app, in place of four hand-copied
203+
paths. ADR 0012. Sign in, a refused sign in, sign out and the whole reset loop
204+
including the emailed link were walked in a browser against the running stack.
205+
- **The images.** Both services bundle to one file per entry point and neither
206+
runtime image has a `node_modules`. The API image is 240 MB rather than 475,
207+
of which 231 MB is the `node:24.20-alpine` base and 6.9 MB is the application.
208+
The door image is 232 MB rather than 271. Every entry point in the API image
209+
was run: migrate, seed, make-admin, and the server signing a seeded member in.
210+
ADR 0013.
211+
212+
One more thing turned up while running the baseline. `password-reset.test.ts`
213+
failed rather than skipping without `DATABASE_URL`, because Vitest runs a
214+
skipped describe's body to collect the names in it and that body called
215+
`loadConfig` with an empty URL. It uses the harness's `testConfig` now, and the
216+
suite skips the way `README.md` says it does.
194217

195218
## 8. Open licence questions
196219

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ pnpm dev
192192
pnpm check # lint, typecheck, test
193193
```
194194

195-
593 tests. The ones that need a database read `DATABASE_URL` and skip with a
195+
631 tests. The ones that need a database read `DATABASE_URL` and skip with a
196196
message when it is not set. The door service tests run against a fake controller
197197
that speaks the real wire protocol, so they need no hardware.
198198

apps/admin/README.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,17 @@ reads as a sentence rather than a 409, a slot above 199 is marked as one the
7575
reader cannot see, and the guard sends every wrong role to the refusal screen
7676
rather than into a directory it cannot read.
7777

78-
They render with `renderToString` from `@vue/test-utils` rather than `mount`,
79-
because neither `jsdom` nor `happy-dom` is installed in this workspace. That is
80-
why the logic worth asserting on lives in `src/lib` and in props rather than
81-
inside a click handler: the views fetch and hold state, the components under
82-
them take props and emit what an admin asked for, and both halves can be tested
83-
without a DOM.
78+
The logic worth asserting on lives in `src/lib` and in props rather than inside
79+
a click handler: the views fetch and hold state, and the components under them
80+
take props and emit what an admin asked for.
81+
82+
The privileged controls are also tested as sequences rather than as states,
83+
under jsdom, because this app grants roles and opens a building. One click never
84+
turns card access on, a card is assigned only after the question naming the
85+
member is answered, the roles form sends only what changed and never card
86+
access, the rear unlock button sends nothing, and the door controls send nothing
87+
while the last report is stale. `src/test-support/interact.ts` finds a control
88+
by the words on it, the way a person does.
8489

8590
## What it depends on
8691

apps/admin/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@vitejs/plugin-vue": "catalog:",
2222
"@vue/test-utils": "catalog:",
2323
"@vue/tsconfig": "catalog:",
24+
"jsdom": "catalog:",
2425
"typescript": "catalog:",
2526
"vite": "catalog:",
2627
"vitest": "catalog:",

apps/admin/src/components/CardAccessControl.test.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { ApiError } from '@hsl/api-client'
2-
import { renderToString } from '@vue/test-utils'
2+
import { mount, renderToString } from '@vue/test-utils'
33
import { describe, expect, it } from 'vitest'
44

5+
import { attached, click } from '../test-support/interact.ts'
56
import CardAccessControl from './CardAccessControl.vue'
67

78
const base = { memberName: 'Sam Rivera', saving: false, error: null }
@@ -38,3 +39,67 @@ describe('CardAccessControl', () => {
3839
expect(html).toContain('That needs an admin.')
3940
})
4041
})
42+
43+
/**
44+
* Card access is a building key and the control asks twice. Rendering proves
45+
* the first question is on the screen and the second is not. Only clicking
46+
* proves the second cannot be reached without answering the first.
47+
*/
48+
describe('CardAccessControl, clicked', () => {
49+
it('asks rather than granting, so one click never opens a building', async () => {
50+
const wrapper = mount(CardAccessControl, { ...attached, props: { ...base, cardAccess: false } })
51+
52+
await click(wrapper, 'Turn card access on')
53+
54+
expect(wrapper.emitted('set')).toBeUndefined()
55+
expect(wrapper.text()).toContain('Give Sam Rivera card access?')
56+
})
57+
58+
it('grants it once the second question is answered', async () => {
59+
const wrapper = mount(CardAccessControl, { ...attached, props: { ...base, cardAccess: false } })
60+
61+
await click(wrapper, 'Turn card access on')
62+
await click(wrapper, 'Yes, do it')
63+
64+
expect(wrapper.emitted('set')).toEqual([[true]])
65+
})
66+
67+
it('takes it away from a member who has it, rather than setting it again', async () => {
68+
const wrapper = mount(CardAccessControl, { ...attached, props: { ...base, cardAccess: true } })
69+
70+
await click(wrapper, 'Turn card access off')
71+
await click(wrapper, 'Yes, do it')
72+
73+
expect(wrapper.emitted('set')).toEqual([[false]])
74+
})
75+
76+
it('changes nothing when the question is cancelled', async () => {
77+
const wrapper = mount(CardAccessControl, { ...attached, props: { ...base, cardAccess: false } })
78+
79+
await click(wrapper, 'Turn card access on')
80+
await click(wrapper, 'Cancel')
81+
82+
expect(wrapper.emitted('set')).toBeUndefined()
83+
expect(wrapper.text()).toContain('Turn card access on')
84+
})
85+
86+
it('will not send a second time while the first is still saving', async () => {
87+
const wrapper = mount(CardAccessControl, { ...attached, props: { ...base, cardAccess: false } })
88+
await click(wrapper, 'Turn card access on')
89+
await wrapper.setProps({ saving: true })
90+
91+
await click(wrapper, 'Saving')
92+
93+
expect(wrapper.emitted('set')).toBeUndefined()
94+
})
95+
96+
it('drops the stale question when the answer arrives', async () => {
97+
const wrapper = mount(CardAccessControl, { ...attached, props: { ...base, cardAccess: false } })
98+
await click(wrapper, 'Turn card access on')
99+
100+
await wrapper.setProps({ cardAccess: true })
101+
102+
expect(wrapper.text()).not.toContain('Give Sam Rivera card access?')
103+
expect(wrapper.text()).toContain('Card access is on')
104+
})
105+
})

apps/admin/src/components/CardAssignForm.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { ApiError } from '@hsl/api-client'
2-
import { renderToString } from '@vue/test-utils'
2+
import { mount, renderToString } from '@vue/test-utils'
33
import { describe, expect, it } from 'vitest'
44

5+
import { attached, click, fill, optionLabels } from '../test-support/interact.ts'
56
import { directoryEntries } from '../test-fixtures.ts'
67
import CardAssignForm from './CardAssignForm.vue'
78

@@ -94,3 +95,76 @@ describe('CardAssignForm', () => {
9495
expect(html).toContain('1061 members match')
9596
})
9697
})
98+
99+
/**
100+
* Assigning a card takes a slot on a physical controller and gives somebody a
101+
* way into the building. The form picks the member, the question names them,
102+
* and nothing is sent until the question is answered.
103+
*/
104+
describe('CardAssignForm, filled in', () => {
105+
function form() {
106+
return mount(CardAssignForm, { ...attached, props: { ...base, members: directoryEntries } })
107+
}
108+
109+
it('reviews before assigning, so picking a member sends nothing', async () => {
110+
const wrapper = form()
111+
112+
await fill(wrapper, 'Member', 'mbr_volkov')
113+
await click(wrapper, 'Review')
114+
115+
expect(wrapper.emitted('assign')).toBeUndefined()
116+
expect(wrapper.text()).toContain('Give card 0004B1C7 to M. Volkov?')
117+
})
118+
119+
it('assigns the member who was picked once the question is answered', async () => {
120+
const wrapper = form()
121+
122+
await fill(wrapper, 'Member', 'mbr_volkov')
123+
await click(wrapper, 'Review')
124+
await click(wrapper, 'Yes, assign it')
125+
126+
expect(wrapper.emitted('assign')).toEqual([
127+
[{ userId: 'mbr_volkov', cardNumber: '0004B1C7' }],
128+
])
129+
})
130+
131+
it('carries a typed label, trimmed, and leaves it out when it is blank', async () => {
132+
const wrapper = form()
133+
134+
await fill(wrapper, 'Member', 'mbr_rivera')
135+
await fill(wrapper, 'Label, optional', ' blue fob ')
136+
await click(wrapper, 'Review')
137+
await click(wrapper, 'Yes, assign it')
138+
139+
expect(wrapper.emitted('assign')).toEqual([
140+
[{ userId: 'mbr_rivera', cardNumber: '0004B1C7', label: 'blue fob' }],
141+
])
142+
})
143+
144+
it('goes back to the form without assigning', async () => {
145+
const wrapper = form()
146+
147+
await fill(wrapper, 'Member', 'mbr_rivera')
148+
await click(wrapper, 'Review')
149+
await click(wrapper, 'Back')
150+
151+
expect(wrapper.emitted('assign')).toBeUndefined()
152+
expect(wrapper.text()).toContain('Search name or email')
153+
})
154+
155+
it('narrows the list to what was searched, over name and email both', async () => {
156+
const wrapper = form()
157+
158+
await fill(wrapper, 'Search name or email', 'volkov')
159+
160+
expect(optionLabels(wrapper, 'Member')).toEqual(['Pick a member', 'M. Volkov'])
161+
})
162+
163+
it('will not review until a member is picked', async () => {
164+
const wrapper = form()
165+
166+
await click(wrapper, 'Review')
167+
168+
expect(wrapper.text()).not.toContain('Yes, assign it')
169+
})
170+
})

apps/admin/src/components/DoorControlPanel.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { DoorStatusResponse } from '@hsl/schema'
2-
import { renderToString } from '@vue/test-utils'
2+
import { mount, renderToString } from '@vue/test-utils'
33
import { describe, expect, it } from 'vitest'
44

5+
import { attached, buttonLabels, click, isDisabled } from '../test-support/interact.ts'
56
import { doorReportingLive } from '../test-fixtures.ts'
67
import DoorControlPanel from './DoorControlPanel.vue'
78

@@ -68,3 +69,56 @@ describe('DoorControlPanel', () => {
6869
expect(html).toContain('Open front was refused.')
6970
})
7071
})
72+
73+
/**
74+
* The controls that open a building. Rendering proves the rear unlock button is
75+
* on the screen and disabled. Only clicking proves that pressing it sends
76+
* nothing, which is the part the 2018 lab decision is about.
77+
*/
78+
describe('DoorControlPanel, clicked', () => {
79+
function panel(door: DoorStatusResponse = doorReportingLive) {
80+
return mount(DoorControlPanel, { ...attached, props: { ...base, door } })
81+
}
82+
83+
it('sends the command on the button that was pressed', async () => {
84+
const wrapper = panel()
85+
86+
await click(wrapper, 'Open front')
87+
88+
expect(wrapper.emitted('send')).toEqual([['open-front', 'Open front']])
89+
})
90+
91+
it('sends nothing when the rear unlock button is pressed, per the 2018 decision', async () => {
92+
const wrapper = panel()
93+
94+
expect(isDisabled(wrapper, 'Unlock rear')).toBe(true)
95+
await click(wrapper, 'Unlock rear')
96+
97+
expect(wrapper.emitted('send')).toBeUndefined()
98+
})
99+
100+
it('sends nothing while the last report is too old to read as live', async () => {
101+
const wrapper = panel({ ...doorReportingLive, stale: true })
102+
103+
await click(wrapper, 'Open front')
104+
await click(wrapper, 'Lock all')
105+
106+
expect(wrapper.emitted('send')).toBeUndefined()
107+
})
108+
109+
it('sends nothing while another command is already in flight', async () => {
110+
const wrapper = panel()
111+
await wrapper.setProps({ busy: 'open-front' })
112+
113+
await click(wrapper, 'Lock all')
114+
115+
expect(wrapper.emitted('send')).toBeUndefined()
116+
})
117+
118+
it('offers no alarm toggle at all when there is no status to toggle', () => {
119+
const wrapper = panel({ status: null, reportedAt: null, stale: true })
120+
121+
expect(buttonLabels(wrapper)).not.toContain('Arm alarm')
122+
expect(buttonLabels(wrapper)).not.toContain('Disarm alarm')
123+
})
124+
})

0 commit comments

Comments
 (0)