Repository navigation
Expand file tree
/
Copy pathknowledge-source.test.ts
More file actions
136 lines (123 loc) · 7.15 KB
/
Copy pathknowledge-source.test.ts
File metadata and controls
136 lines (123 loc) · 7.15 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
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// `KnowledgeRefreshPolicySchema.cron` — pins for the typed cron slot (#14825).
//
// The slot was a bare `z.string()` under a doc comment promising a 5-field
// cron, so the promise was enforced nowhere (ADR-0049 declared ≠ enforced). It
// now carries `CronExpressionInputSchema`, the shared cron-dialect input the
// other cron-shaped fields already use (`api/export.zod.ts`,
// `automation/execution.zod.ts`, `integration/connector.zod.ts`). What that
// schema ENFORCES was measured before these pins were written, and the pins
// state exactly that — no more:
//
// - a bare non-blank string normalizes to `{ dialect: 'cron', source }`;
// - a `{ dialect: 'cron' }` envelope passes through;
// - a blank string (empty or whitespace-only), a non-string, or an envelope
// naming any dialect but `cron` is refused with ONE `invalid_union` at the
// slot's own path whose message names the fix — the shared dialect fixed its
// envelope arm and learned to trim at #15028 / #15035; the sentences are
// `TYPED_EXPRESSION_SOURCE_REQUIRED.cron` / `TYPED_EXPRESSION_DIALECT_ONLY.cron`;
// - cron SYNTAX is not judged at parse time, and no engine judges it later
// either. `'not a cron'` normalizes like any other string, and this slot is
// parsed and reaches no engine: `croner` judges a cron pattern only where a
// schedule is wired (`CronSchedule.expression`, a different slot), and
// `@objectstack/formula`'s registered `cron` engine has no caller outside
// that package. The syntax verdict belongs to whatever external scheduler
// the author hands the value to. That pin is deliberate: it is what keeps the
// schema's describe honest. If the shared dialect ever gains parse-time
// syntax validation, this pin flips, and the describe on the slot must be
// rewritten in the same commit.
import { describe, expect, it } from 'vitest';
import { TYPED_EXPRESSION_DIALECT_ONLY, TYPED_EXPRESSION_SOURCE_REQUIRED } from '../shared/expression.zod';
import {
KnowledgeRefreshPolicySchema,
KnowledgeSourceSchema,
type KnowledgeRefreshPolicy,
type KnowledgeRefreshPolicyParsed,
type KnowledgeSource,
type KnowledgeSourceParsed,
} from './knowledge-source.zod';
const SOURCE: KnowledgeSource = {
id: 'kb_articles',
label: 'KB articles',
adapter: 'memory',
source: { kind: 'object', object: 'kb_article', contentFields: ['title', 'body'] },
};
const CRON_5_FIELD = '0 3 * * *';
describe('KnowledgeRefreshPolicySchema.cron — the typed cron slot', () => {
it('positive control: a 5-field cron on a full knowledge source parses and normalizes to the cron envelope', () => {
const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } });
expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true);
if (!r.success) return;
expect(r.data.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
});
it('accepts the expression envelope form and passes it through', () => {
const envelope = { dialect: 'cron' as const, source: '@daily' };
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: envelope });
expect(r.success).toBe(true);
if (!r.success) return;
expect(r.data.cron).toEqual(envelope);
});
it('absent stays absent — no `cron` key is fabricated by the parse', () => {
const withEmptyRefresh = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: {} });
expect(withEmptyRefresh.success).toBe(true);
if (withEmptyRefresh.success) expect(withEmptyRefresh.data.refresh?.cron).toBeUndefined();
const withoutRefresh = KnowledgeSourceSchema.safeParse(SOURCE);
expect(withoutRefresh.success).toBe(true);
if (withoutRefresh.success) expect(withoutRefresh.data.refresh?.cron).toBeUndefined();
});
it.each([['the empty string', ''], ['whitespace only', ' \t']])(
'refuses a blank string (%s) with ONE `invalid_union` at `refresh.cron` whose message is the cron source-required sentence',
(_label, blank) => {
const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: blank } });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.'), i.message])).toEqual([
['invalid_union', 'refresh.cron', TYPED_EXPRESSION_SOURCE_REQUIRED.cron],
]);
},
);
it('refuses a non-string value with `invalid_union` at `cron`', () => {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: 42 });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]);
});
it.each([
['a dialect the protocol does not declare (`js`, a retired expression dialect, ADR-0058 addendum)', 'js'],
['a declared dialect that is not this slot\'s (`cel`)', 'cel'],
])('refuses an envelope naming %s with ONE `invalid_union` at `cron` whose message is the cron dialect-only sentence', (_label, dialect) => {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: { dialect, source: 'x' } });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.'), i.message])).toEqual([
['invalid_union', 'cron', TYPED_EXPRESSION_DIALECT_ONLY.cron],
]);
});
it('does NOT judge cron syntax at parse time — measured, and the describe promises no more (declared = enforced)', () => {
// Nothing evaluates this slot, so no engine ever issues a syntax verdict on
// it: `croner` judges `CronSchedule.expression`, a different slot, and
// `@objectstack/formula`'s registered `cron` engine has no caller outside
// that package. The verdict belongs to whatever external scheduler the
// author hands the value to. The parse only normalizes. If this case
// ever goes red because the shared dialect learned to refuse syntax, update
// the slot's describe in the same commit — do not weaken this pin.
for (const source of ['not a cron', '0 0 3 * * *', '@daily']) {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: source });
expect(r.success, `${JSON.stringify(source)} should normalize, not be refused`).toBe(true);
if (r.success) expect(r.data.cron).toEqual({ dialect: 'cron', source });
}
});
it('names both states (ADR-0122): the bare alias is the author state, `XParsed` the parsed state', () => {
// Author state: a bare string is what an author writes.
const authored: KnowledgeRefreshPolicy = { cron: CRON_5_FIELD };
// Parsed state: the envelope is what a consumer holds after the parse.
const parsed: KnowledgeRefreshPolicyParsed = KnowledgeRefreshPolicySchema.parse(authored);
expect(parsed.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
// @ts-expect-error — a bare string is the AUTHOR shape, not the parsed one.
const notParsed: KnowledgeRefreshPolicyParsed = { cron: CRON_5_FIELD };
expect(notParsed).toBeDefined();
const parsedSource: KnowledgeSourceParsed = KnowledgeSourceSchema.parse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } });
expect(parsedSource.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
});
});