Skip to content

Commit 4416293

Browse files
committed
test(objectql): engine pins for a conditional when reading through a lookup
Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD Co-authored-by: Claude <noreply@anthropic.com>
1 parent cdfc86c commit 4416293

1 file changed

Lines changed: 73 additions & 0 deletions

File tree

‎packages/objectql/src/engine-predicate-relationship.test.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,79 @@ describe('#18682 — engine-produced relationship bindings', () => {
284284
});
285285
});
286286

287+
// [#24512] A `conditional` rule's `when` reading one hop through the lookup:
288+
// the engine preloads the hop for it and `checkConditional` evaluates `when`
289+
// on the related row, so the rule picks its branch instead of refusing every
290+
// write it guards as unevaluable.
291+
describe('[#24512] a `conditional` rule\'s `when` reads through the lookup', () => {
292+
const THEN = 'GATE-THEN: partner deals are capped at 10000.';
293+
const OTHERWISE = 'GATE-OTHERWISE: direct deals start at 100.';
294+
const gate = (when: string, name = 'partner_gate') => ({
295+
name, type: 'conditional', severity: 'error', message: 'unused', when,
296+
then: { name: 'gate_cap', type: 'script', severity: 'error', message: THEN, condition: 'record.amount > 10000' },
297+
otherwise: { name: 'gate_floor', type: 'script', severity: 'error', message: OTHERWISE, condition: 'record.amount < 100' },
298+
});
299+
const register = (validations: unknown[]) => {
300+
engine.registry.registerObject({
301+
name: 'crm_opportunity',
302+
fields: {
303+
name: { type: 'text' }, amount: { type: 'number' },
304+
account: { type: 'lookup', reference: 'crm_account' },
305+
},
306+
validations,
307+
} as any, 'test-package');
308+
d.calls.length = 0;
309+
};
310+
/** The rejection envelope, or `null` when the write is accepted. */
311+
const insert = async (data: Record<string, unknown>): Promise<{ code?: string; fields: any[] } | null> => {
312+
try {
313+
await engine.insert('crm_opportunity', data, { context: ACTING } as any);
314+
return null;
315+
} catch (e) {
316+
const err = e as { code?: string; fields?: any[] };
317+
return { code: err.code, fields: err.fields ?? [] };
318+
}
319+
};
320+
const messagesOf = (r: { fields: any[] } | null): string[] => (r?.fields ?? []).map((f) => String(f.message));
321+
322+
it('picks `then` for a partner account and `otherwise` for any other, on the real related row', async () => {
323+
register([gate("record.account.type == 'partner'")]);
324+
const partner = await insert({ name: 'A', amount: 50000, account: 'acc_p' });
325+
expect(partner?.code).toBe('VALIDATION_FAILED');
326+
expect(partner?.fields).toEqual([expect.objectContaining({ code: 'rule_violation', message: THEN })]);
327+
const direct = await insert({ name: 'B', amount: 50, account: 'acc_d' });
328+
expect(direct?.code).toBe('VALIDATION_FAILED');
329+
expect(direct?.fields).toEqual([expect.objectContaining({ code: 'rule_violation', message: OTHERWISE })]);
330+
// Each branch admits what it does not refuse, and the other branch is not run.
331+
expect(await insert({ name: 'C', amount: 50, account: 'acc_p' })).toBeNull();
332+
expect(await insert({ name: 'D', amount: 50000, account: 'acc_d' })).toBeNull();
333+
});
334+
335+
it('preloads the `when` hop: one system read, projecting only the column `when` names', async () => {
336+
register([gate("record.account.type == 'partner'")]);
337+
await insert({ name: 'A', amount: 10, account: 'acc_d' });
338+
expect(relatedReads()).toHaveLength(1);
339+
expect([...(relatedReads()[0].ast?.fields ?? [])].sort()).toEqual(['id', 'type']);
340+
});
341+
342+
it('a `when` naming an undeclared related field refuses alone; a sibling `when` through the same lookup keeps its verdict', async () => {
343+
register([gate("record.account.type == 'partner'"), gate("record.account.not_a_column == 'x'", 'sneaky_gate')]);
344+
const direct = await insert({ name: 'A', amount: 50000, account: 'acc_d' });
345+
expect(direct?.code).toBe('VALIDATION_FAILED');
346+
expect(direct?.fields).toEqual([expect.objectContaining({
347+
code: 'rule_violation',
348+
constraint: expect.objectContaining({ rule: 'sneaky_gate', reason: 'unevaluable' }),
349+
})]);
350+
expect(messagesOf(direct)[0]).toContain("'crm_account' declares no 'not_a_column'");
351+
// The valid gate, on a partner account, refuses with its own branch's message.
352+
const partner = await insert({ name: 'B', amount: 50000, account: 'acc_p' });
353+
expect(messagesOf(partner)).toContain(THEN);
354+
expect(partner?.fields.map((f) => f.constraint?.rule ?? null)).toEqual([null, 'sneaky_gate']);
355+
// The shared read never names the undeclared column.
356+
for (const call of relatedReads()) expect(call.ast?.fields ?? []).not.toContain('not_a_column');
357+
});
358+
});
359+
287360
// The refusal is about the WRITE, never about the value. A caller can infer a
288361
// value by observing refusals — an accepted, deliberately narrow channel — so
289362
// nothing may make the refusal more informative than "this rule refused".

0 commit comments

Comments
 (0)