|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// `KnowledgeRefreshPolicySchema.cron` — pins for the typed cron slot (#14825). |
| 4 | +// |
| 5 | +// The slot was a bare `z.string()` under a doc comment promising a 5-field |
| 6 | +// cron, so the promise was enforced nowhere (ADR-0049 declared ≠ enforced). It |
| 7 | +// now carries `CronExpressionInputSchema`, the shared cron-dialect input the |
| 8 | +// other cron-shaped fields already use (`api/export.zod.ts`, |
| 9 | +// `automation/execution.zod.ts`, `integration/connector.zod.ts`). What that |
| 10 | +// schema ENFORCES was measured before these pins were written, and the pins |
| 11 | +// state exactly that — no more: |
| 12 | +// |
| 13 | +// - a bare non-empty string normalizes to `{ dialect: 'cron', source }`; |
| 14 | +// - an expression envelope passes through; |
| 15 | +// - an empty string, a non-string, or an envelope naming an unknown dialect |
| 16 | +// is refused with `invalid_union` at the slot's own path; |
| 17 | +// - cron SYNTAX is not judged at parse time. `'not a cron'` normalizes like |
| 18 | +// any other string: the syntax verdict belongs to the `cron` dialect engine |
| 19 | +// (`@objectstack/formula` cron-engine — 5- or 6-field, or an `@` alias) when |
| 20 | +// the expression is evaluated. That pin is deliberate: it is what keeps the |
| 21 | +// schema's describe honest. If the shared dialect ever gains parse-time |
| 22 | +// syntax validation, this pin flips, and the describe on the slot must be |
| 23 | +// rewritten in the same commit. |
| 24 | + |
| 25 | +import { describe, expect, it } from 'vitest'; |
| 26 | +import { |
| 27 | + KnowledgeRefreshPolicySchema, |
| 28 | + KnowledgeSourceSchema, |
| 29 | + type KnowledgeRefreshPolicy, |
| 30 | + type KnowledgeRefreshPolicyParsed, |
| 31 | + type KnowledgeSource, |
| 32 | + type KnowledgeSourceParsed, |
| 33 | +} from './knowledge-source.zod'; |
| 34 | + |
| 35 | +const SOURCE: KnowledgeSource = { |
| 36 | + id: 'kb_articles', |
| 37 | + label: 'KB articles', |
| 38 | + adapter: 'memory', |
| 39 | + source: { kind: 'object', object: 'kb_article', contentFields: ['title', 'body'] }, |
| 40 | +}; |
| 41 | + |
| 42 | +const CRON_5_FIELD = '0 3 * * *'; |
| 43 | + |
| 44 | +describe('KnowledgeRefreshPolicySchema.cron — the typed cron slot (#14825)', () => { |
| 45 | + it('positive control: a 5-field cron on a full knowledge source parses and normalizes to the cron envelope', () => { |
| 46 | + const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } }); |
| 47 | + expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true); |
| 48 | + if (!r.success) return; |
| 49 | + expect(r.data.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD }); |
| 50 | + }); |
| 51 | + |
| 52 | + it('accepts the expression envelope form and passes it through', () => { |
| 53 | + const envelope = { dialect: 'cron' as const, source: '@daily' }; |
| 54 | + const r = KnowledgeRefreshPolicySchema.safeParse({ cron: envelope }); |
| 55 | + expect(r.success).toBe(true); |
| 56 | + if (!r.success) return; |
| 57 | + expect(r.data.cron).toEqual(envelope); |
| 58 | + }); |
| 59 | + |
| 60 | + it('absent stays absent — no `cron` key is fabricated by the parse', () => { |
| 61 | + const withEmptyRefresh = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: {} }); |
| 62 | + expect(withEmptyRefresh.success).toBe(true); |
| 63 | + if (withEmptyRefresh.success) expect(withEmptyRefresh.data.refresh?.cron).toBeUndefined(); |
| 64 | + |
| 65 | + const withoutRefresh = KnowledgeSourceSchema.safeParse(SOURCE); |
| 66 | + expect(withoutRefresh.success).toBe(true); |
| 67 | + if (withoutRefresh.success) expect(withoutRefresh.data.refresh?.cron).toBeUndefined(); |
| 68 | + }); |
| 69 | + |
| 70 | + it('refuses an empty string with `invalid_union` at `refresh.cron`', () => { |
| 71 | + const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: '' } }); |
| 72 | + expect(r.success).toBe(false); |
| 73 | + if (r.success) return; |
| 74 | + const issue = r.error.issues.find((i) => i.path.join('.') === 'refresh.cron'); |
| 75 | + expect(issue, JSON.stringify(r.error.issues)).toBeDefined(); |
| 76 | + expect(issue?.code).toBe('invalid_union'); |
| 77 | + expect(issue?.message.split('.')[0]).toBe('Invalid input'); |
| 78 | + }); |
| 79 | + |
| 80 | + it('refuses a non-string value with `invalid_union` at `cron`', () => { |
| 81 | + const r = KnowledgeRefreshPolicySchema.safeParse({ cron: 42 }); |
| 82 | + expect(r.success).toBe(false); |
| 83 | + if (r.success) return; |
| 84 | + expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]); |
| 85 | + }); |
| 86 | + |
| 87 | + it('refuses an envelope naming a dialect the protocol does not declare', () => { |
| 88 | + // `js` was retired from `ExpressionDialect` (#3278, ADR-0058 addendum). |
| 89 | + const r = KnowledgeRefreshPolicySchema.safeParse({ cron: { dialect: 'js', source: 'x' } }); |
| 90 | + expect(r.success).toBe(false); |
| 91 | + if (r.success) return; |
| 92 | + expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]); |
| 93 | + }); |
| 94 | + |
| 95 | + it('does NOT judge cron syntax at parse time — measured, and the describe promises no more (declared = enforced)', () => { |
| 96 | + // The syntax verdict is the `cron` dialect engine's at evaluate time: |
| 97 | + // `@objectstack/formula` cron-engine accepts 5- or 6-field expressions and |
| 98 | + // the `@yearly`…`@reboot` aliases. The parse only normalizes. If this case |
| 99 | + // ever goes red because the shared dialect learned to refuse syntax, update |
| 100 | + // the slot's describe in the same commit — do not weaken this pin. |
| 101 | + for (const source of ['not a cron', '0 0 3 * * *', '@daily']) { |
| 102 | + const r = KnowledgeRefreshPolicySchema.safeParse({ cron: source }); |
| 103 | + expect(r.success, `${JSON.stringify(source)} should normalize, not be refused`).toBe(true); |
| 104 | + if (r.success) expect(r.data.cron).toEqual({ dialect: 'cron', source }); |
| 105 | + } |
| 106 | + }); |
| 107 | + |
| 108 | + it('names both states (ADR-0122): the bare alias is the author state, `XParsed` the parsed state', () => { |
| 109 | + // Author state: a bare string is what an author writes. |
| 110 | + const authored: KnowledgeRefreshPolicy = { cron: CRON_5_FIELD }; |
| 111 | + // Parsed state: the envelope is what a consumer holds after the parse. |
| 112 | + const parsed: KnowledgeRefreshPolicyParsed = KnowledgeRefreshPolicySchema.parse(authored); |
| 113 | + expect(parsed.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD }); |
| 114 | + // @ts-expect-error — a bare string is the AUTHOR shape, not the parsed one. |
| 115 | + const notParsed: KnowledgeRefreshPolicyParsed = { cron: CRON_5_FIELD }; |
| 116 | + expect(notParsed).toBeDefined(); |
| 117 | + |
| 118 | + const parsedSource: KnowledgeSourceParsed = KnowledgeSourceSchema.parse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } }); |
| 119 | + expect(parsedSource.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD }); |
| 120 | + }); |
| 121 | +}); |
0 commit comments