Skip to content

Commit 1b15a2e

Browse files
committed
test(dogfood): pin a validation rule's undeclared lookup field on a booted stack
Claude-Session: https://claude.ai/code/session_01XPyxHgkHDoFuEza6RvCJtd Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9b348f0 commit 1b15a2e

1 file changed

Lines changed: 143 additions & 0 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#24468] A validation rule that reads, through a lookup, a field the
4+
// lookup's target does not declare — on a real boot: the real `ObjectQL`, SQL
5+
// driver, REST, auth and metadata layers.
6+
//
7+
// ## The two halves
8+
//
9+
// - The save door. `PUT /api/v1/meta/object/:name` refuses a rule condition
10+
// reading `record.account.region` when `rrh_account` declares no `region`,
11+
// under the existing `EXPRESSION_INVALID` rule, naming the field and the
12+
// target object. The same object with only the rule reading a declared field
13+
// saves.
14+
// - The write. A rule already stored with such a read (here: declared in code,
15+
// which this door never judged) refuses every write, naming its own field;
16+
// a VALID sibling rule reading through the SAME lookup keeps the verdict it
17+
// gives alone — it admits an order on an open account and refuses one on a
18+
// closed account with its own message.
19+
//
20+
// Fixtures are synthetic. ⚠️ No test title states a value.
21+
22+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
23+
import { bootStack, type VerifyStack } from '@objectstack/verify';
24+
import { defineStack } from '@objectstack/spec';
25+
import { ObjectSchema, Field } from '@objectstack/spec/data';
26+
27+
const SYS = { context: { isSystem: true } } as const;
28+
/** The valid rule: an order needs an account that is not closed. */
29+
const VALID = "record.account.status == 'closed'";
30+
const VALID_MESSAGE = 'RRHVALID81: the account is closed';
31+
/** Reads a field `rrh_account` does not declare. */
32+
const UNDECLARED = "record.account.region == 'north'";
33+
34+
const object = (name: string, fields: Record<string, unknown>, validations: unknown[]) =>
35+
ObjectSchema.create({ name, label: name, pluralLabel: name, sharingModel: 'public_read_write', fields, validations } as never);
36+
37+
const orderFields = () => ({
38+
label: Field.text({ label: 'Label' }),
39+
account: Field.lookup('rrh_account', { label: 'Account' }),
40+
});
41+
const validRule = { name: 'open_account', type: 'script', severity: 'error', message: VALID_MESSAGE, condition: VALID };
42+
const badRule = { name: 'north_only', type: 'script', severity: 'error', message: 'RRHBAD82', condition: UNDECLARED };
43+
44+
const fixtureStack = defineStack({
45+
manifest: {
46+
id: 'com.dogfood.rule-hop-undeclared', namespace: 'rrh', version: '0.0.0', type: 'app',
47+
name: 'Rule Hop Undeclared Field Fixture',
48+
description: 'Orders whose validation rules read through a lookup to an account.',
49+
},
50+
objects: [
51+
object('rrh_account', { status: Field.text({ label: 'Status' }) }, []),
52+
// The stored bad rule: declared in code, beside the valid sibling.
53+
object('rrh_order', orderFields(), [validRule, badRule]),
54+
// Control: the valid rule alone.
55+
object('rrh_order_alone', orderFields(), [validRule]),
56+
],
57+
});
58+
59+
/** The object body a `PUT /meta/object/:name` writes: plain JSON, as Studio or an AI author sends it. */
60+
const saveBody = (name: string, validations: unknown[]) => ({
61+
name,
62+
label: name,
63+
pluralLabel: name,
64+
sharingModel: 'public_read_write',
65+
fields: {
66+
label: { type: 'text', label: 'Label' },
67+
account: { type: 'lookup', label: 'Account', reference: 'rrh_account' },
68+
},
69+
validations,
70+
});
71+
72+
type Row = Record<string, any>;
73+
74+
describe('[#24468] a validation rule reading a field its lookup target does not declare', () => {
75+
let stack: VerifyStack;
76+
let token = '';
77+
let openId = '';
78+
let closedId = '';
79+
80+
const call = async (method: string, path: string, body?: unknown) => {
81+
const res = await stack.apiAs(token, method, path, body);
82+
const json: any = await res.json().catch(() => ({}));
83+
return { status: res.status, json };
84+
};
85+
/** Every refusal text the write's response carries, joined. */
86+
const refusalText = (json: any): string => JSON.stringify(json ?? {});
87+
88+
beforeAll(async () => {
89+
stack = await bootStack(fixtureStack as unknown as Parameters<typeof bootStack>[0]);
90+
const ql = (await stack.kernel.getServiceAsync('objectql')) as any;
91+
token = await stack.signIn();
92+
openId = String((await ql.insert('rrh_account', { status: 'open' }, SYS)).id);
93+
closedId = String((await ql.insert('rrh_account', { status: 'closed' }, SYS)).id);
94+
}, 180_000);
95+
96+
afterAll(async () => {
97+
await stack?.stop();
98+
});
99+
100+
it('the save door refuses the rule, naming the field and the target object', async () => {
101+
const saved = await call('PUT', '/meta/object/rrh_order_saved', saveBody('rrh_order_saved', [validRule, badRule]));
102+
expect(saved.status, JSON.stringify(saved.json)).toBe(422);
103+
expect(saved.json?.code, JSON.stringify(saved.json)).toBe('INVALID_METADATA');
104+
const issues = (saved.json?.issues ?? []) as Row[];
105+
// The existing `EXPRESSION_INVALID` rule, located at the rule the author edits.
106+
expect(issues.map((i) => [i.rule, i.where]), JSON.stringify(saved.json)).toEqual([
107+
['expression-invalid', "object 'rrh_order_saved' · validation 'north_only'"],
108+
]);
109+
expect(String(issues[0].message)).toMatch(/^unknown field `region` on `rrh_account`/);
110+
});
111+
112+
it('control: the same object with only the rule reading a declared field saves', async () => {
113+
const saved = await call('PUT', '/meta/object/rrh_order_ok', saveBody('rrh_order_ok', [validRule]));
114+
expect(saved.status, JSON.stringify(saved.json)).toBe(200);
115+
});
116+
117+
it('control: the valid rule alone admits an open account and refuses a closed one', async () => {
118+
const open = await call('POST', '/data/rrh_order_alone', { label: 'open', account: openId });
119+
expect(open.status, JSON.stringify(open.json)).toBe(201);
120+
const closed = await call('POST', '/data/rrh_order_alone', { label: 'closed', account: closedId });
121+
expect(closed.status, JSON.stringify(closed.json)).toBe(400);
122+
expect(refusalText(closed.json)).toContain(VALID_MESSAGE);
123+
});
124+
125+
it('beside a stored bad rule, the valid rule keeps its own verdict and the bad one names only its own field', async () => {
126+
// An open account: the valid rule admits it; only the bad rule refuses,
127+
// and the refusal names `region`, never the valid rule.
128+
const open = await call('POST', '/data/rrh_order', { label: 'open', account: openId });
129+
expect(open.status, JSON.stringify(open.json)).toBe(400);
130+
const openText = refusalText(open.json);
131+
expect(openText).toContain("Validation rule 'north_only' could not be evaluated");
132+
expect(openText).toContain("declares no 'region'");
133+
expect(openText).not.toContain("Validation rule 'open_account'");
134+
expect(openText).not.toContain(VALID_MESSAGE);
135+
136+
// A closed account: the valid rule refuses with its OWN message, as alone.
137+
const closed = await call('POST', '/data/rrh_order', { label: 'closed', account: closedId });
138+
expect(closed.status, JSON.stringify(closed.json)).toBe(400);
139+
const closedText = refusalText(closed.json);
140+
expect(closedText).toContain(VALID_MESSAGE);
141+
expect(closedText).not.toContain("Validation rule 'open_account' could not be evaluated");
142+
});
143+
});

0 commit comments

Comments
 (0)