-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathbatch-row-http-status-real-driver.integration.test.ts
More file actions
261 lines (234 loc) · 12.2 KB
/
Copy pathbatch-row-http-status-real-driver.integration.test.ts
File metadata and controls
261 lines (234 loc) · 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* [#8570] A batch row's `httpStatus`, measured on the REAL stack — a real
* `ObjectQL` over a real `SqlDriver` on better-sqlite3, through the real
* `ObjectStackProtocolImplementation`'s bulk-write loops.
*
* ## What this file is for
*
* The card's first row was measured here, not argued:
*
* ```json
* { "code": "VALIDATION_FAILED", "message": "name must be ≤ 4 characters (got 15)" }
* ```
*
* — a well-defined 400 shipping with no status at all, beside siblings in the
* SAME response that carried one, because the limb read `err.status` and
* objectql's `ValidationError` deliberately declares none (deciding it means
* 400 is "the job of whichever boundary serves it", per `@objectstack/types`'
* `validation-failure.ts`). Nothing below builds that error: a real record is
* malformed against a real schema and the engine's own validator rejects it.
*
* ## Both directions, on the real stack
*
* The fix delegates to `resolveThrownHttpError`, which answers for EVERY
* throw — including the ones that are not refusals at all. So the driver-fault
* case is here too, and it is the half that fails if the limb ever stamps the
* resolver's `status` (500, the fallback) instead of its `declaredStatus`:
* a `SqliteError` must keep carrying NO status, exactly as before this card.
* A file that only asserted the newly-populated row would be green either way.
*
* The card's second row — `plugin-approvals`' `RECORD_LOCKED`, spelled
* `statusCode: 409` — is driven through the REAL lock hook in
* `packages/plugins/plugin-approvals/src/record-lock-batch-row-status.integration.test.ts`,
* which is where both halves of that producer can be imported.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { ObjectQL } from '@objectstack/objectql';
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
import { SqlDriver } from '@objectstack/driver-sql';
import { provisionRawForeignKey } from './raw-foreign-key-fixture.js';
import {
captureExpectedReadRefusals,
type ExpectedReadRefusalCapture,
} from './expected-read-refusal-noise.js';
import { resolveThrownHttpError, validationFailureDetails } from '@objectstack/types';
/** `maxLength` is what the real record validator rejects against. */
const TASK = {
name: 'hs_task',
fields: {
name: { type: 'text', maxLength: 4 },
progress: { type: 'number' },
},
};
const PARENT = { name: 'hs_parent', fields: { name: { type: 'text' } } };
const CHILD = {
name: 'hs_child',
fields: {
name: { type: 'text' },
// ⚠️ Deliberately NOT declared as a lookup — see
// `raw-foreign-key-fixture.ts`. A canonical `reference` would let the
// ENGINE see the relationship and apply `deleteBehavior` on delete,
// clearing the child BEFORE the parent delete ever reaches the
// database — which dissolves the raw driver fault this suite exists to
// withhold. The column still carries a real FOREIGN KEY (raw DDL); what
// it must not carry is a relationship the engine will resolve for it.
parent: { type: 'text' },
},
};
/**
* [#10629] This fixture provisions its three business objects and nothing else,
* so the engine's single-tenant probe (`ObjectQL.probeInstallOrganizations`,
* memoised once per engine) reads a `sys_organization` that was never created.
* The probe is fail-soft by construction — it catches `isMissingTableError` and
* only that — but the driver and the engine each log the fault on the way out.
*
* ⛔ Asserted in the three tests that WRITE rather than in `afterEach`: the
* probe runs on the system-write org resolution, so the not-found test (which
* only deletes a ghost id) never reaches it. An `afterEach` assertion would
* make that test red for a reason that has nothing to do with it.
* `expected-read-refusal-noise.ts` says why this withholds instead of muting.
*/
const ABSENT_TENANCY_TABLE = 'sys_organization';
/** [#10629] The capture is a PIN, not a mute — this is the assertion half. */
const expectExpectedNoiseWithheld = (noise: ExpectedReadRefusalCapture | null): void => {
expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]);
};
describe('[#8570] a batch row carries the status its producer DECLARED — real driver', () => {
/** [#10629] The expected-noise capture belonging to the latest rig. */
let noise: ExpectedReadRefusalCapture | null = null;
let dir: string | null = null;
let engine: ObjectQL | null = null;
afterEach(async () => {
try { await engine?.destroy(); } catch { /* noop */ }
engine = null;
if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; }
});
async function rig() {
dir = mkdtempSync(join(tmpdir(), 'os-8570-real-'));
const real = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: join(dir, 'data.sqlite') },
useNullAsDefault: true,
});
// [#10629] Installed on the REAL driver (the one that logs) before it
// runs a statement — the `Object.create(real)` wrapper below resolves
// `logger` through the prototype chain to this sink.
noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]);
noise.captureDriver(real);
// [#11567] The FK this suite's raw-fault vehicle depends on is built
// with RAW DDL, because the driver no longer emits one: `reference_to`
// (a key `FieldSchema` refuses) used to gate `createColumn`'s FK
// emission, and that branch is retired. The lookup below is now spelled
// the canonical way and contributes NO constraint — see
// `raw-foreign-key-fixture.ts` for why the vehicle was kept rather than
// dropped along with the key.
await provisionRawForeignKey(real, 'hs_parent', 'hs_child', 'parent');
await real.initObjects([TASK, PARENT, CHILD]);
// Capture the RAW error at the seam and let it propagate untouched, so
// the assertions below are about what the producer really threw rather
// than about an assumption about it.
let raw: any = null;
const driver: any = Object.create(real);
for (const m of ['create', 'update', 'delete', 'bulkCreate'] as const) {
driver[m] = async (...args: any[]) => {
try { return await (real as any)[m](...args); } catch (e) { raw ??= e; throw e; }
};
}
engine = new ObjectQL();
noise.captureEngine(engine);
engine.registerDriver(driver, true);
await engine.init();
for (const o of [TASK, PARENT, CHILD]) {
engine.registry.registerObject(o as any, 'com.objectstack.test.8570');
}
const protocol: any = new ObjectStackProtocolImplementation(engine as any);
return { protocol, rawOf: () => raw };
}
it('the REAL validator: the card\'s first row, verbatim, now carrying 400', async () => {
const { protocol } = await rig();
await engine!.insert('hs_task', { id: 'ok1', name: 'ok', progress: 0 });
// 15 characters against `maxLength: 4` — the card's own row.
const TOO_LONG = 'fifteen chars!!';
expect(TOO_LONG).toHaveLength(15);
const res: any = await protocol.updateManyData({
object: 'hs_task',
records: [{ id: 'ok1', data: { name: TOO_LONG } }],
});
const error = res.results[0].errors[0];
expect(res.results[0].success).toBe(false);
// The card's measured row, plus the limb it was missing.
expect(error).toEqual({
code: 'VALIDATION_FAILED',
message: 'name must be ≤ 4 characters (got 15)',
httpStatus: 400,
});
// Non-vacuity on the PRODUCER: this row gains a status only because
// the fix reads the declaration the shape carries — the thrown error
// really does spell no status in either channel, so a limb reading
// `err.status` (or `err.statusCode`) still answers nothing for it.
let thrown: any = null;
try {
await engine!.update('hs_task', { name: TOO_LONG }, { where: { id: 'ok1' } } as any);
} catch (e) { thrown = e; }
expect(thrown).not.toBeNull();
expect(thrown.name).toBe('ValidationError');
expect(thrown.code).toBe('VALIDATION_FAILED');
expect(thrown.status).toBeUndefined();
expect(thrown.statusCode).toBeUndefined();
expect(validationFailureDetails(thrown)).toBeDefined();
expect(resolveThrownHttpError(thrown).declaredStatus).toBe(400);
// …and the record is unchanged, so the refusal was a refusal.
expect((await engine!.findOne('hs_task', { where: { id: 'ok1' } }))?.name).toBe('ok');
expectExpectedNoiseWithheld(noise);
});
it('the same row in a MIXED batch — the asymmetry the card measured is gone', async () => {
// The complaint was not "no row has a status", it was "some rows in one
// response do and others do not, with nothing saying which". So the two
// populations are driven into ONE response here.
const { protocol } = await rig();
await engine!.insert('hs_task', { id: 'ok1', name: 'ok', progress: 0 });
const res: any = await protocol.updateManyData({
object: 'hs_task',
records: [
{ data: { progress: 1 } }, // no id → rowRequiredIdError (400)
{ id: 'ok1', data: { name: 'fifteen chars!!' } }, // real ValidationError
],
options: { continueOnError: true },
});
expect(res.results[0].errors[0]).toEqual({
code: 'VALIDATION_FAILED', message: 'Record id is required for update', httpStatus: 400,
});
expect(res.results[1].errors[0].code).toBe('VALIDATION_FAILED');
expect(res.results[1].errors[0].httpStatus).toBe(400);
expectExpectedNoiseWithheld(noise);
});
it('a REAL driver fault still carries NO status — the over-broad direction', async () => {
// `resolveThrownHttpError(raw).status` is 500 here, and 500 is what an
// unconditional stamp would put on this row. It never carried one and
// must not start: that is an addition to the wire for a population that
// declared nothing, which is not what this card does.
const { protocol, rawOf } = await rig();
await engine!.insert('hs_parent', { id: 'p1', name: 'kept' });
await engine!.insert('hs_child', { id: 'c1', name: 'dependent', parent: 'p1' });
const res: any = await protocol.deleteManyData({ object: 'hs_parent', ids: ['p1'] });
const raw = rawOf();
expect(raw).not.toBeNull();
expect(raw.code).toBe('SQLITE_CONSTRAINT_FOREIGNKEY');
expect(raw.status).toBeUndefined();
expect(raw.statusCode).toBeUndefined();
// The production recogniser, on the real throw: a 500 that nobody
// declared. Both halves asserted, because the whole fix is the gap
// between them.
expect(resolveThrownHttpError(raw).status).toBe(500);
expect(resolveThrownHttpError(raw).declaredStatus).toBeUndefined();
const error = res.results[0].errors[0];
expect(res.results[0].success).toBe(false);
expect(Object.prototype.hasOwnProperty.call(error, 'httpStatus')).toBe(false);
// Nowhere in the payload either — `reconcileStoppedBatch` copies the
// causal row's text onto its siblings, so a row-only scan can miss a
// live path.
expect(JSON.stringify(res)).not.toContain('httpStatus');
expectExpectedNoiseWithheld(noise);
});
it('a not-found row still answers 404 — the population that already worked', async () => {
const { protocol } = await rig();
const res: any = await protocol.deleteManyData({ object: 'hs_task', ids: ['ghost'] });
expect(res.results[0].errors[0]).toEqual({
code: 'RECORD_NOT_FOUND', message: 'Record ghost not found in hs_task', httpStatus: 404,
});
});
});