@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.
- package/package.json +1 -1
- package/payload/platform/lib/active-rules/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/active-rules/dist/index.js +32 -1
- package/payload/platform/lib/active-rules/dist/index.js.map +1 -1
- package/payload/platform/lib/active-rules/src/index.test.ts +52 -3
- package/payload/platform/lib/active-rules/src/index.ts +36 -2
- package/payload/platform/lib/dispatch-read/dist/allocate.d.ts +57 -0
- package/payload/platform/lib/dispatch-read/dist/allocate.d.ts.map +1 -0
- package/payload/platform/lib/dispatch-read/dist/allocate.js +33 -0
- package/payload/platform/lib/dispatch-read/dist/allocate.js.map +1 -0
- package/payload/platform/lib/dispatch-read/dist/index.d.ts +2 -0
- package/payload/platform/lib/dispatch-read/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/dispatch-read/dist/index.js +10 -1
- package/payload/platform/lib/dispatch-read/dist/index.js.map +1 -1
- package/payload/platform/lib/dispatch-read/src/__tests__/allocate.test.ts +41 -0
- package/payload/platform/lib/dispatch-read/src/allocate.ts +89 -0
- package/payload/platform/lib/dispatch-read/src/index.ts +14 -0
- package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +7 -2
- package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +8 -2
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +2 -2
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +8 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visit-status-write.test.ts +333 -22
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visits-scope.test.ts +262 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +126 -21
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visit-status.ts +142 -2
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visits.ts +118 -6
- package/payload/platform/plugins/dispatch/PLUGIN.md +2 -0
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts +19 -50
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts.map +1 -1
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js +1 -28
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js.map +1 -1
- package/payload/platform/plugins/dispatch/skills/dispatch-page/SKILL.md +45 -6
- package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.css +60 -0
- package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.html +13 -0
- package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.js +203 -5
- package/payload/platform/plugins/docs/references/internals.md +1 -1
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.d.ts +2 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.d.ts.map +1 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.js +19 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.js.map +1 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.d.ts +2 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.d.ts.map +1 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.js +110 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.js.map +1 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.d.ts +2 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.d.ts.map +1 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.js +22 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.js.map +1 -0
- package/payload/platform/plugins/telegram/mcp/dist/index.js +19 -163
- package/payload/platform/plugins/telegram/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.d.ts +9 -0
- package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.d.ts.map +1 -1
- package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.js +11 -0
- package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.js.map +1 -1
- package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.d.ts +30 -3
- package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.d.ts.map +1 -1
- package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.js +29 -4
- package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.js.map +1 -1
- package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.d.ts +33 -0
- package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.d.ts.map +1 -0
- package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.js +145 -0
- package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.js.map +1 -0
- package/payload/platform/plugins/telegram/skills/configure/SKILL.md +4 -2
- package/payload/platform/services/telegram-channel/dist/notification.d.ts +35 -3
- package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/telegram-channel/dist/notification.js +38 -5
- package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/telegram-channel/dist/server.js +1 -1
- package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
- package/payload/platform/services/webchat-channel/dist/notification.d.ts +2 -1
- package/payload/platform/services/webchat-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/webchat-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +2 -2
- package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
- 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(
|
|
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
|
-
|
|
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
|
+
})
|