@rubytech/create-maxy-code 0.1.576 → 0.1.577

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/active-rules/dist/index.d.ts.map +1 -1
  3. package/payload/platform/lib/active-rules/dist/index.js +32 -1
  4. package/payload/platform/lib/active-rules/dist/index.js.map +1 -1
  5. package/payload/platform/lib/active-rules/src/index.test.ts +52 -3
  6. package/payload/platform/lib/active-rules/src/index.ts +36 -2
  7. package/payload/platform/lib/dispatch-read/dist/allocate.d.ts +57 -0
  8. package/payload/platform/lib/dispatch-read/dist/allocate.d.ts.map +1 -0
  9. package/payload/platform/lib/dispatch-read/dist/allocate.js +33 -0
  10. package/payload/platform/lib/dispatch-read/dist/allocate.js.map +1 -0
  11. package/payload/platform/lib/dispatch-read/dist/index.d.ts +2 -0
  12. package/payload/platform/lib/dispatch-read/dist/index.d.ts.map +1 -1
  13. package/payload/platform/lib/dispatch-read/dist/index.js +10 -1
  14. package/payload/platform/lib/dispatch-read/dist/index.js.map +1 -1
  15. package/payload/platform/lib/dispatch-read/src/__tests__/allocate.test.ts +41 -0
  16. package/payload/platform/lib/dispatch-read/src/allocate.ts +89 -0
  17. package/payload/platform/lib/dispatch-read/src/index.ts +14 -0
  18. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +7 -2
  19. package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +8 -2
  20. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +2 -2
  21. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +8 -0
  22. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visit-status-write.test.ts +333 -22
  23. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visits-scope.test.ts +262 -0
  24. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +126 -21
  25. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visit-status.ts +142 -2
  26. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visits.ts +118 -6
  27. package/payload/platform/plugins/dispatch/PLUGIN.md +2 -0
  28. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts +19 -50
  29. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts.map +1 -1
  30. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js +1 -28
  31. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js.map +1 -1
  32. package/payload/platform/plugins/dispatch/skills/dispatch-page/SKILL.md +45 -6
  33. package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.css +60 -0
  34. package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.html +13 -0
  35. package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.js +203 -5
  36. package/payload/platform/plugins/docs/references/internals.md +1 -1
  37. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.d.ts +2 -0
  38. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.d.ts.map +1 -0
  39. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.js +19 -0
  40. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.js.map +1 -0
  41. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.d.ts +2 -0
  42. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.d.ts.map +1 -0
  43. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.js +110 -0
  44. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.js.map +1 -0
  45. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.d.ts +2 -0
  46. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.d.ts.map +1 -0
  47. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.js +22 -0
  48. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.js.map +1 -0
  49. package/payload/platform/plugins/telegram/mcp/dist/index.js +19 -163
  50. package/payload/platform/plugins/telegram/mcp/dist/index.js.map +1 -1
  51. package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.d.ts +9 -0
  52. package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.d.ts.map +1 -1
  53. package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.js +11 -0
  54. package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.js.map +1 -1
  55. package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.d.ts +30 -3
  56. package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.d.ts.map +1 -1
  57. package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.js +29 -4
  58. package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.js.map +1 -1
  59. package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.d.ts +33 -0
  60. package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.d.ts.map +1 -0
  61. package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.js +145 -0
  62. package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.js.map +1 -0
  63. package/payload/platform/plugins/telegram/skills/configure/SKILL.md +4 -2
  64. package/payload/platform/services/telegram-channel/dist/notification.d.ts +35 -3
  65. package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
  66. package/payload/platform/services/telegram-channel/dist/notification.js +38 -5
  67. package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
  68. package/payload/platform/services/telegram-channel/dist/server.js +1 -1
  69. package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
  70. package/payload/platform/services/webchat-channel/dist/notification.d.ts +2 -1
  71. package/payload/platform/services/webchat-channel/dist/notification.d.ts.map +1 -1
  72. package/payload/platform/services/webchat-channel/dist/notification.js.map +1 -1
  73. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +2 -2
  74. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  75. package/payload/server/server.js +508 -117
@@ -1,6 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import {
3
3
  MAX_NOTE_LENGTH,
4
+ INSTANT_RE,
5
+ MAX_OWNER_ID_LENGTH,
4
6
  MAX_STATUS_LENGTH,
5
7
  WORK_DATE_RE,
6
8
  processVisitStatus,
@@ -14,6 +16,9 @@ interface Row {
14
16
  accountId: string
15
17
  ownerId: string
16
18
  visitId: string
19
+ /** The visit's CURRENT status, which the Task 2511 transition pre-read needs
20
+ * to know what a move is moving from. */
21
+ status?: string
17
22
  }
18
23
 
19
24
  /**
@@ -23,7 +28,11 @@ interface Row {
23
28
  * `meta.changes` as the number of rows the SELECT admitted. A fake that always
24
29
  * reported one change would pass a handler with no gate at all.
25
30
  */
26
- function db(rows: Row[], person: { ownerId: string; recordScope: string; accountId: string }) {
31
+ function db(
32
+ rows: Row[],
33
+ person: { ownerId: string; recordScope: string; accountId: string },
34
+ transitions: Array<[string, string]> | string = [],
35
+ ) {
27
36
  const inserted: unknown[][] = []
28
37
  const database = {
29
38
  prepare(sql: string) {
@@ -34,7 +43,25 @@ function db(rows: Row[], person: { ownerId: string; recordScope: string; account
34
43
  return stmt
35
44
  },
36
45
  async first<T>() {
37
- return person as unknown as T
46
+ // Three reads now use first(): the session resolve, and the Task 2511
47
+ // pre-read of the visit's current status with the account's published
48
+ // transitions. Branching on the table is what keeps the fake honest
49
+ // about which one answered, and the pre-read APPLIES THE SAME TWO
50
+ // FILTERS the insert does, so a test that deletes the scope branch in
51
+ // the endpoint sees the pre-read stop matching too.
52
+ if (!sql.includes('FROM visits')) return person as unknown as T
53
+ const acct = bound[0] as string
54
+ const visitId = bound[bound.length - 1] as string
55
+ let admitted = rows.filter((r) => r.accountId === acct && r.visitId === visitId)
56
+ if (sql.includes('AND v.ownerId = ?')) {
57
+ admitted = admitted.filter((r) => r.ownerId === (bound[1] as string))
58
+ }
59
+ if (admitted.length === 0) return null as unknown as T
60
+ return {
61
+ status: admitted[0].status ?? '',
62
+ transitions:
63
+ typeof transitions === 'string' ? transitions : JSON.stringify(transitions),
64
+ } as unknown as T
38
65
  },
39
66
  async run() {
40
67
  if (!sql.startsWith('INSERT INTO visit_status')) return { meta: { changes: 0 } }
@@ -62,10 +89,25 @@ const env = (d: D1Database): PortalEnv =>
62
89
  ({ DB: d, BUCKET: {}, PORTAL_ACCOUNT_ID: 'acc-a' }) as unknown as PortalEnv
63
90
 
64
91
  const ROWS: Row[] = [
65
- { accountId: 'acc-a', ownerId: 'alice', visitId: 'v1' },
66
- { accountId: 'acc-a', ownerId: 'bob', visitId: 'v2' },
67
- { accountId: 'acc-a', ownerId: '', visitId: 'v3' },
68
- { accountId: 'acc-b', ownerId: 'alice', visitId: 'v4' },
92
+ { accountId: 'acc-a', ownerId: 'alice', visitId: 'v1', status: 'booked' },
93
+ { accountId: 'acc-a', ownerId: 'bob', visitId: 'v2', status: 'booked' },
94
+ { accountId: 'acc-a', ownerId: '', visitId: 'v3', status: 'booked' },
95
+ { accountId: 'acc-b', ownerId: 'alice', visitId: 'v4', status: 'booked' },
96
+ ]
97
+
98
+ /**
99
+ * Every move the tests BELOW this line make, declared legal.
100
+ *
101
+ * Those tests measure the scope gate and the field gates, not the transition
102
+ * gate Task 2511 added. Without this list each of them would be refused as an
103
+ * illegal move and would stop measuring what it was written to measure. The
104
+ * transition gate has its own describe block at the end of this file, where the
105
+ * list is stated per test.
106
+ */
107
+ const DECLARED: Array<[string, string]> = [
108
+ ['booked', 'on-site'],
109
+ ['booked', 'done'],
110
+ ['booked', 'on-site-at-granary-court'],
69
111
  ]
70
112
 
71
113
  const person = (ownerId: string, recordScope: string) => ({
@@ -78,7 +120,7 @@ const body = (visitId: string, status = 'on-site') => ({ visitId, status })
78
120
 
79
121
  describe('the visit-status write gate', () => {
80
122
  it('accepts an own-scoped person setting status on their own visit', async () => {
81
- const { database, inserted } = db(ROWS, person('alice', 'own'))
123
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
82
124
  const h = await processVisitStatus(body('v1'), 'sess', env(database), () => {}, 1)
83
125
  expect(h.status).toBe(200)
84
126
  expect(inserted).toHaveLength(1)
@@ -87,28 +129,28 @@ describe('the visit-status write gate', () => {
87
129
  it('refuses an own-scoped person setting status on someone else visit', async () => {
88
130
  // A read leak shows someone a day that is not theirs. A write leak lets
89
131
  // them change it, which is why the gate is the same one and is structural.
90
- const { database, inserted } = db(ROWS, person('alice', 'own'))
132
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
91
133
  const h = await processVisitStatus(body('v2'), 'sess', env(database), () => {}, 1)
92
134
  expect(h.status).toBe(404)
93
135
  expect(inserted).toEqual([])
94
136
  })
95
137
 
96
138
  it('refuses an own-scoped person setting status on an unbound visit', async () => {
97
- const { database, inserted } = db(ROWS, person('alice', 'own'))
139
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
98
140
  const h = await processVisitStatus(body('v3'), 'sess', env(database), () => {}, 1)
99
141
  expect(h.status).toBe(404)
100
142
  expect(inserted).toEqual([])
101
143
  })
102
144
 
103
145
  it('lets an account-scoped person set status on any visit for the account', async () => {
104
- const { database, inserted } = db(ROWS, person('marie', 'account'))
146
+ const { database, inserted } = db(ROWS, person('marie', 'account'), DECLARED)
105
147
  const h = await processVisitStatus(body('v2'), 'sess', env(database), () => {}, 1)
106
148
  expect(h.status).toBe(200)
107
149
  expect(inserted).toHaveLength(1)
108
150
  })
109
151
 
110
152
  it('refuses another account visit even at account scope', async () => {
111
- const { database, inserted } = db(ROWS, person('marie', 'account'))
153
+ const { database, inserted } = db(ROWS, person('marie', 'account'), DECLARED)
112
154
  const h = await processVisitStatus(body('v4'), 'sess', env(database), () => {}, 1)
113
155
  expect(h.status).toBe(404)
114
156
  expect(inserted).toEqual([])
@@ -123,7 +165,7 @@ describe('the visit-status write gate', () => {
123
165
  })
124
166
 
125
167
  it('refuses a body with no visitId or no status', async () => {
126
- const { database, inserted } = db(ROWS, person('alice', 'own'))
168
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
127
169
  const e = env(database)
128
170
  expect((await processVisitStatus({ status: 'x' }, 'sess', e, () => {}, 1)).status).toBe(400)
129
171
  expect((await processVisitStatus({ visitId: 'v1' }, 'sess', e, () => {}, 1)).status).toBe(400)
@@ -135,7 +177,7 @@ describe('the visit-status write gate', () => {
135
177
  // The value reaches :Visit.status through the pull. An unbounded free-text
136
178
  // field written from the open web is refused at the door, not truncated:
137
179
  // a truncated status is a different status, silently.
138
- const { database, inserted } = db(ROWS, person('alice', 'own'))
180
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
139
181
  const h = await processVisitStatus(
140
182
  body('v1', 'x'.repeat(MAX_STATUS_LENGTH + 1)),
141
183
  'sess',
@@ -148,7 +190,7 @@ describe('the visit-status write gate', () => {
148
190
  })
149
191
 
150
192
  it('stamps the setter own id, never one the request supplies', async () => {
151
- const { database, inserted } = db(ROWS, person('alice', 'own'))
193
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
152
194
  await processVisitStatus(
153
195
  { visitId: 'v1', status: 'on-site', ownerId: 'bob' },
154
196
  'sess',
@@ -162,7 +204,7 @@ describe('the visit-status write gate', () => {
162
204
 
163
205
  it('logs the scope and the outcome, and no status text', async () => {
164
206
  const lines: string[] = []
165
- const { database } = db(ROWS, person('alice', 'own'))
207
+ const { database } = db(ROWS, person('alice', 'own'), DECLARED)
166
208
  await processVisitStatus(
167
209
  body('v1', 'on-site-at-granary-court'),
168
210
  'sess',
@@ -181,7 +223,7 @@ describe('the working day a status edit may carry', () => {
181
223
  const day = (extra: Record<string, unknown>) => ({ visitId: 'v1', status: 'done', ...extra })
182
224
 
183
225
  it('inserts the day, the date and the note when the claim is well formed', async () => {
184
- const { database, inserted } = db(ROWS, person('alice', 'own'))
226
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
185
227
  const h = await processVisitStatus(
186
228
  day({ dayHundredths: 100, workDate: '2026-08-05', workNote: 'second fix' }),
187
229
  'sess',
@@ -197,7 +239,7 @@ describe('the working day a status edit may carry', () => {
197
239
 
198
240
  it('defaults to no claim when the body carries no day', async () => {
199
241
  // A status-only edit is the common case and must stay a one-field write.
200
- const { database, inserted } = db(ROWS, person('alice', 'own'))
242
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
201
243
  const h = await processVisitStatus(day({}), 'sess', env(database), () => {}, 1)
202
244
  expect(h.status).toBe(200)
203
245
  expect(inserted[0]).toContain(0)
@@ -207,7 +249,7 @@ describe('the working day a status edit may carry', () => {
207
249
  it('refuses a day outside 0..100 rather than writing it into the graph', async () => {
208
250
  // A value above a full day is described by no shipped surface. Refused at
209
251
  // the door, exactly as an over-long status is.
210
- const { database, inserted } = db(ROWS, person('alice', 'own'))
252
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
211
253
  const e = env(database)
212
254
  for (const d of [101, -1, 50.5, '100']) {
213
255
  const h = await processVisitStatus(
@@ -225,7 +267,7 @@ describe('the working day a status edit may carry', () => {
225
267
  it('refuses a claim whose date is missing or malformed', async () => {
226
268
  // The date becomes part of the cost key. A claim the roll-up cannot date is
227
269
  // told to the person now, not left sitting in the queue.
228
- const { database, inserted } = db(ROWS, person('alice', 'own'))
270
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
229
271
  const e = env(database)
230
272
  for (const wd of [undefined, '', '5th Aug', '2026-8-5']) {
231
273
  const h = await processVisitStatus(
@@ -246,7 +288,7 @@ describe('the working day a status edit may carry', () => {
246
288
  })
247
289
 
248
290
  it('refuses a note longer than the cap rather than truncating it', async () => {
249
- const { database, inserted } = db(ROWS, person('alice', 'own'))
291
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
250
292
  const h = await processVisitStatus(
251
293
  day({
252
294
  dayHundredths: 100,
@@ -264,7 +306,7 @@ describe('the working day a status edit may carry', () => {
264
306
 
265
307
  it('logs whether the edit carried a day, and never the note', async () => {
266
308
  const lines: string[] = []
267
- const { database } = db(ROWS, person('alice', 'own'))
309
+ const { database } = db(ROWS, person('alice', 'own'), DECLARED)
268
310
  await processVisitStatus(
269
311
  day({ dayHundredths: 100, workDate: '2026-08-05', workNote: 'granary court rewire' }),
270
312
  'sess',
@@ -280,7 +322,7 @@ describe('the working day a status edit may carry', () => {
280
322
 
281
323
  it('still refuses another person visit when the edit carries a day', async () => {
282
324
  // The gate is the SELECT and the new fields must not have moved it.
283
- const { database, inserted } = db(ROWS, person('alice', 'own'))
325
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
284
326
  const h = await processVisitStatus(
285
327
  { visitId: 'v2', status: 'done', dayHundredths: 100, workDate: '2026-08-05' },
286
328
  'sess',
@@ -292,3 +334,272 @@ describe('the working day a status edit may carry', () => {
292
334
  expect(inserted).toEqual([])
293
335
  })
294
336
  })
337
+
338
+ describe('the move a status edit may carry (Task 2511)', () => {
339
+ const move = (extra: Record<string, unknown>) => ({ visitId: 'v1', status: 'on-site', ...extra })
340
+
341
+ it('inserts the new owner and the new start when both are well formed', async () => {
342
+ const { database, inserted } = db(ROWS, person('marie', 'account'), DECLARED)
343
+ const h = await processVisitStatus(
344
+ move({ assignedOwnerId: 'bob', startDate: '2026-08-10T09:00:00.000Z' }),
345
+ 'sess',
346
+ env(database),
347
+ () => {},
348
+ 1,
349
+ )
350
+ expect(h.status).toBe(200)
351
+ expect(inserted[0]).toContain('bob')
352
+ expect(inserted[0]).toContain('2026-08-10T09:00:00.000Z')
353
+ })
354
+
355
+ it('defaults both to empty when the body carries neither', async () => {
356
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
357
+ const h = await processVisitStatus(move({}), 'sess', env(database), () => {}, 1)
358
+ expect(h.status).toBe(200)
359
+ // status, statusAt, workDate, workNote, assignedOwnerId, startDate: four of
360
+ // those six are empty on a status-only edit.
361
+ expect(inserted[0].filter((v) => v === '').length).toBeGreaterThanOrEqual(4)
362
+ })
363
+
364
+ it('refuses an offset-bearing, date-only or second-precision start rather than converting it', async () => {
365
+ // readCommitments compares stored start values LEXICALLY, so an
366
+ // offset-bearing row reads the wrong window and a real double-booking goes
367
+ // unreported. Refused at the door, never normalised here.
368
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
369
+ const e = env(database)
370
+ for (const s of [
371
+ '2026-08-10T09:00:00+01:00',
372
+ '2026-08-10',
373
+ '2026-08-10T09:00:00Z',
374
+ '2026-08-10 09:00:00.000Z',
375
+ 'soon',
376
+ ]) {
377
+ const h = await processVisitStatus(move({ startDate: s }), 'sess', e, () => {}, 1)
378
+ expect(h.status, `refuses ${s}`).toBe(400)
379
+ }
380
+ expect(inserted).toEqual([])
381
+ })
382
+
383
+ it('refuses an over-long assignedOwnerId rather than truncating it', async () => {
384
+ // A truncated id matches a different person, or nobody, silently.
385
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
386
+ const h = await processVisitStatus(
387
+ move({ assignedOwnerId: 'x'.repeat(MAX_OWNER_ID_LENGTH + 1) }),
388
+ 'sess',
389
+ env(database),
390
+ () => {},
391
+ 1,
392
+ )
393
+ expect(h.status).toBe(400)
394
+ expect(inserted).toEqual([])
395
+ })
396
+
397
+ it('still refuses another person visit when the edit carries a move', async () => {
398
+ // The gate is the SELECT and the new fields must not have moved it.
399
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
400
+ const h = await processVisitStatus(
401
+ { visitId: 'v2', status: 'on-site', assignedOwnerId: 'bob' },
402
+ 'sess',
403
+ env(database),
404
+ () => {},
405
+ 1,
406
+ )
407
+ expect(h.status).toBe(404)
408
+ expect(inserted).toEqual([])
409
+ })
410
+
411
+ it('accepts a start only in the shape the tree stores', () => {
412
+ expect(INSTANT_RE.test('2026-08-10T09:00:00.000Z')).toBe(true)
413
+ expect(INSTANT_RE.test('2026-08-10T09:00:00Z')).toBe(false)
414
+ expect(INSTANT_RE.test('2026-08-10')).toBe(false)
415
+ })
416
+
417
+ it('names the visit and the reason when it refuses a bad instant', async () => {
418
+ const lines: string[] = []
419
+ const { database } = db(ROWS, person('alice', 'own'), DECLARED)
420
+ await processVisitStatus(
421
+ move({ startDate: 'soon' }),
422
+ 'sess',
423
+ env(database),
424
+ (l) => lines.push(l),
425
+ 1,
426
+ )
427
+ expect(lines.join('\n')).toContain('op=rejected')
428
+ expect(lines.join('\n')).toContain('reason=bad-instant')
429
+ expect(lines.join('\n')).toContain('v1')
430
+ })
431
+ })
432
+
433
+ describe('the transition gate at the door (Task 2511)', () => {
434
+ it('accepts a move the account declared', async () => {
435
+ const { database, inserted } = db(ROWS, person('marie', 'account'), [['booked', 'on-site']])
436
+ const h = await processVisitStatus(
437
+ { visitId: 'v1', status: 'on-site' },
438
+ 'sess',
439
+ env(database),
440
+ () => {},
441
+ 1,
442
+ )
443
+ expect(h.status).toBe(200)
444
+ expect(inserted).toHaveLength(1)
445
+ })
446
+
447
+ it('refuses a move the account did not declare, and writes nothing', async () => {
448
+ // The refusal is here and not at the pull, which runs sixty seconds later
449
+ // with no screen to refuse to, and which sets the status unconditionally.
450
+ const { database, inserted } = db(ROWS, person('marie', 'account'), [['booked', 'on-site']])
451
+ const h = await processVisitStatus(
452
+ { visitId: 'v1', status: 'done' },
453
+ 'sess',
454
+ env(database),
455
+ () => {},
456
+ 1,
457
+ )
458
+ expect(h.status).toBe(400)
459
+ expect(h.payload).toEqual({ ok: false, error: 'illegal-transition' })
460
+ expect(inserted).toEqual([])
461
+ })
462
+
463
+ it('lets a re-time through when the status is unchanged', async () => {
464
+ // `[from, to]` declares a MOVE. A write whose status equals the current one
465
+ // is not a move, and no account declares a self-transition, so without this
466
+ // rule every pure re-timing would be refused.
467
+ const { database, inserted } = db(ROWS, person('marie', 'account'), [['booked', 'on-site']])
468
+ const h = await processVisitStatus(
469
+ { visitId: 'v1', status: 'booked', startDate: '2026-08-10T09:00:00.000Z' },
470
+ 'sess',
471
+ env(database),
472
+ () => {},
473
+ 1,
474
+ )
475
+ expect(h.status).toBe(200)
476
+ expect(inserted).toHaveLength(1)
477
+ })
478
+
479
+ it('refuses every move when the account published no transitions', async () => {
480
+ // An empty list means the account declared none, which refuses everything.
481
+ // That is visibly wrong on screen; accepting everything would leave the page
482
+ // enforcing nothing and looking identical to one that enforces correctly.
483
+ const { database, inserted } = db(ROWS, person('marie', 'account'), [])
484
+ const h = await processVisitStatus(
485
+ { visitId: 'v1', status: 'on-site' },
486
+ 'sess',
487
+ env(database),
488
+ () => {},
489
+ 1,
490
+ )
491
+ expect(h.status).toBe(400)
492
+ expect(inserted).toEqual([])
493
+ })
494
+
495
+ it('refuses every move when the published list is malformed', async () => {
496
+ const { database, inserted } = db(ROWS, person('marie', 'account'), 'not json')
497
+ const h = await processVisitStatus(
498
+ { visitId: 'v1', status: 'on-site' },
499
+ 'sess',
500
+ env(database),
501
+ () => {},
502
+ 1,
503
+ )
504
+ expect(h.status).toBe(400)
505
+ expect(inserted).toEqual([])
506
+ })
507
+
508
+ it('answers 404 for a visit the person may not see, before any transition check', async () => {
509
+ const { database, inserted } = db(ROWS, person('alice', 'own'), [['booked', 'on-site']])
510
+ const h = await processVisitStatus(
511
+ { visitId: 'v2', status: 'on-site' },
512
+ 'sess',
513
+ env(database),
514
+ () => {},
515
+ 1,
516
+ )
517
+ expect(h.status).toBe(404)
518
+ expect(inserted).toEqual([])
519
+ })
520
+
521
+ it('names the reason in the log, with the visit', async () => {
522
+ const lines: string[] = []
523
+ const { database } = db(ROWS, person('marie', 'account'), [['booked', 'on-site']])
524
+ await processVisitStatus(
525
+ { visitId: 'v1', status: 'done' },
526
+ 'sess',
527
+ env(database),
528
+ (l) => lines.push(l),
529
+ 1,
530
+ )
531
+ expect(lines.join('\n')).toContain('op=rejected')
532
+ expect(lines.join('\n')).toContain('reason=illegal-transition')
533
+ expect(lines.join('\n')).toContain('v1')
534
+ })
535
+
536
+ it('still never says whether the visit exists, on a 404', async () => {
537
+ // One answer for "no such visit" and "not yours". Distinguishing them would
538
+ // let anyone enumerate the account's visit ids.
539
+ const { database } = db(ROWS, person('alice', 'own'), [['booked', 'on-site']])
540
+ const missing = await processVisitStatus(
541
+ { visitId: 'nope', status: 'on-site' },
542
+ 'sess',
543
+ env(database),
544
+ () => {},
545
+ 1,
546
+ )
547
+ const theirs = await processVisitStatus(
548
+ { visitId: 'v2', status: 'on-site' },
549
+ 'sess',
550
+ env(database),
551
+ () => {},
552
+ 1,
553
+ )
554
+ expect(missing.payload).toEqual(theirs.payload)
555
+ expect(missing.status).toBe(theirs.status)
556
+ })
557
+ })
558
+
559
+ describe('a move and a labour claim are separate edits (Task 2511)', () => {
560
+ it('refuses a body carrying both, and writes nothing', async () => {
561
+ // The device applies a move in one statement that writes no cost, so a row
562
+ // carrying both would set the status, move the worker, be claimed, and drop
563
+ // the day the worker stated.
564
+ const { database, inserted } = db(ROWS, person('marie', 'account'), DECLARED)
565
+ const e = env(database)
566
+ for (const move of [{ assignedOwnerId: 'bob' }, { startDate: '2026-08-10T09:00:00.000Z' }]) {
567
+ const h = await processVisitStatus(
568
+ { visitId: 'v1', status: 'on-site', dayHundredths: 100, workDate: '2026-08-05', ...move },
569
+ 'sess',
570
+ e,
571
+ () => {},
572
+ 1,
573
+ )
574
+ expect(h.status).toBe(400)
575
+ }
576
+ expect(inserted).toEqual([])
577
+ })
578
+
579
+ it('still accepts each one on its own', async () => {
580
+ const { database, inserted } = db(ROWS, person('marie', 'account'), DECLARED)
581
+ const e = env(database)
582
+ const labour = await processVisitStatus(
583
+ { visitId: 'v1', status: 'done', dayHundredths: 100, workDate: '2026-08-05' },
584
+ 'sess', e, () => {}, 1,
585
+ )
586
+ const move = await processVisitStatus(
587
+ { visitId: 'v1', status: 'on-site', assignedOwnerId: 'bob' },
588
+ 'sess', e, () => {}, 1,
589
+ )
590
+ expect(labour.status).toBe(200)
591
+ expect(move.status).toBe(200)
592
+ expect(inserted).toHaveLength(2)
593
+ })
594
+
595
+ it('names the reason with the visit', async () => {
596
+ const lines: string[] = []
597
+ const { database } = db(ROWS, person('marie', 'account'), DECLARED)
598
+ await processVisitStatus(
599
+ { visitId: 'v1', status: 'on-site', dayHundredths: 100, workDate: '2026-08-05', assignedOwnerId: 'bob' },
600
+ 'sess', env(database), (l) => lines.push(l), 1,
601
+ )
602
+ expect(lines.join('\n')).toContain('reason=move-with-labour')
603
+ expect(lines.join('\n')).toContain('v1')
604
+ })
605
+ })