diff --git a/.changeset/verification-pipeline-repair.md b/.changeset/verification-pipeline-repair.md new file mode 100644 index 00000000..1d63f06b --- /dev/null +++ b/.changeset/verification-pipeline-repair.md @@ -0,0 +1,62 @@ +--- +'hotcrm': patch +--- + +Repair the verification pipeline: fake-green CI checks, a dead e2e suite, and +the runtime test coverage gaps (#495). + +**CI checks that checked nothing.** `code-quality.yml`'s three greps scanned +`packages/` — a directory this repo has never had — each with +`continue-on-error: true`. They are replaced by `scripts/check-source-hygiene.mjs`, +which scans the real tree (`src`, `test`, `e2e`, `scripts`), fails on a hit, and +fails loudly if a scanned directory ever disappears. `deploy-docs.yml` copied +`QUICKSTART.md` and `PROJECT_SUMMARY.md` unguarded — neither exists, so every +triggering push failed the workflow. `continue-on-error` is off `pnpm lint` in +both workflows (`objectstack lint` already exits 0 on warnings, so the flag only +hid real errors). The orphaned `.eslintrc.json` is removed — no `eslint` package +was installed anywhere to read it — and CONTRIBUTING now says what `pnpm lint` +actually does. `.github/labeler.yml` had seven rules, three of which pointed at +paths that have never existed and one (`ui`) at a label the repo does not have; +globs are repointed and `test/labeler-config.test.ts` fails on a glob that +matches nothing. `apps/docs` (Next.js, 231 mdx pages, its own lockfile) is +compiled by a new `docs-app.yml` workflow — nothing built it before. + +**The e2e suite could not run.** `playwright.config.ts` pointed `baseURL` at +port 4004 while the server serves 4001, declared no `webServer`, and no workflow +ran playwright. Both specs also treated the data API's 401 as a pass, so even on +the right port they could only prove a route was mounted. The suite now boots the +server itself, authenticates for real via a shared `globalSetup`, and asserts +unconditionally — including that the data API *is* gated, which the old +`[200, 401, 403]` assertion would have let a public-data regression through. +`retries`/`trace` are configured for CI and `e2e.yml` runs it. 11 specs pass +against a cold database. + +**Typecheck blind spots.** `tsconfig.json` covered only `src/`, so `test/`, +`e2e/` and `scripts/` — including the 606-line analytics-reconcile tool — were +never typechecked. Widening `include` surfaced 45 real errors, all fixed; the +module mode moves to `preserve`/`bundler` (nothing here is emitted by tsc) and +`noEmit` is pinned so no stray `tsc` can scatter output into the `dist/` the +marketplace publishes. `tsx` is now a real dependency behind +`pnpm reconcile:analytics` — the command the script documented could not run. +`scripts/wow1-live-schema.sh` preflights the `ai` capability it needs and +explains why a local server does not provide it. + +**Runtime coverage.** Hooks went from 4 of 24 tested to 24 of 24, and flows from +3 of 20 to 17 of 20 (all six scheduled sweeps included). Statement coverage of +the hook handlers goes from 23% to 95%, functions from 22% to 95%, and +`vitest.config.ts` gains thresholds set just under those numbers. Shared +harnesses replace three divergent copies of the in-memory data engine and add the +query operators the old ones lacked — an equality-only engine silently matches +nothing for the `$lt`/`$nin` filters every scheduled sweep uses, so those flows +could not have been tested against it. `test/runtime-coverage.test.ts` fails when +a hook or flow arrives with no runtime test. + +**Defect these tests surfaced.** Conditional edges nested inside a `loop` body +never evaluate: `applyConversionsToFlow` rewrites a bare string condition into a +CEL envelope only for a flow's top-level edges, so a loop-nested condition falls +through to the engine's legacy path and is string-compared +(`'existingStallTask' === 'null'` → false). `opportunity_stagnation`, +`contract_renewal` and `campaign_enrollment` are inert past that gate. The +behaviour is pinned and documented in `test/flow-scheduled.test.ts` rather than +fixed here — the fix changes what these production sweeps do (they would begin +creating tasks, opportunities and notifications) and belongs in its own change. diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 02fd30d0..00000000 --- a/.eslintrc.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 2022, - "sourceType": "module", - "project": "./tsconfig.json" - }, - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended" - ], - "plugins": [ - "@typescript-eslint" - ], - "env": { - "node": true, - "es2022": true - }, - "rules": { - "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], - "no-console": "off" - } -} diff --git a/.github/labeler.yml b/.github/labeler.yml index 510a0b17..8f25bc66 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,30 +1,49 @@ # Configuration for PR labeler -# Labels PRs based on changed files +# Labels PRs based on changed files. +# +# Two invariants, both of which the previous revision broke: +# +# 1. Every glob must match something in the tree. `backend` pointed at +# `src/server.ts`, `src/engine/**` and `src/triggers/**`; `ui` pointed at +# `src/ui/**` and `tailwind.config.cjs`; `metadata` pointed at +# `src/metadata/**`. None of those paths have ever existed in this repo. +# `test/labeler-config.test.ts` now fails the suite on a glob that matches +# no file. +# +# 2. Every label key must already exist in the repository — `actions/labeler` +# does not create them. The old `ui:` key had no matching repo label, so +# even a corrected glob could not have applied it. The keys below are +# exactly the six labels that exist today: documentation, dependencies, +# ci/cd, backend, metadata, configuration. Adding a key here means creating +# the label in repo settings first. documentation: - changed-files: - - any-glob-to-any-file: ['docs/**', '*.md', 'README*', 'CONTRIBUTING*'] + - any-glob-to-any-file: ['docs/**', 'apps/docs/**', 'src/docs/**', '*.md', 'README*', 'CONTRIBUTING*'] dependencies: - changed-files: - - any-glob-to-any-file: ['package.json', 'package-lock.json'] + - any-glob-to-any-file: ['package.json', 'package-lock.json', 'pnpm-lock.yaml', 'apps/docs/package.json', 'apps/docs/pnpm-lock.yaml'] +# CI plumbing and the verification pipeline itself — workflows, helper scripts, +# and the test suites those workflows run. ci/cd: - changed-files: - - any-glob-to-any-file: ['.github/**'] + - any-glob-to-any-file: ['.github/**', 'scripts/**', 'test/**', 'e2e/**'] +# Server-side behaviour: hook handlers, automation flows, and invocable +# actions. This is the code that actually executes at request time. backend: - changed-files: - - any-glob-to-any-file: ['src/server.ts', 'src/engine/**', 'src/triggers/**', 'src/actions/**'] - -ui: - - changed-files: - - any-glob-to-any-file: ['src/ui/**', 'tailwind.config.cjs'] + - any-glob-to-any-file: ['src/actions/**', 'src/flows/**', 'src/hooks/**', 'src/objects/*.hook.ts'] +# Declarative metadata: schema, the security posture layered on it, and the +# UI surfaces (apps, pages, views, dashboards, reports, datasets) that are +# themselves just metadata in ObjectStack. metadata: - changed-files: - - any-glob-to-any-file: ['src/metadata/**'] + - any-glob-to-any-file: ['src/objects/**', 'src/profiles/**', 'src/sharing/**', 'src/skills/**', 'src/data/**', 'src/apps/**', 'src/pages/**', 'src/views/**', 'src/interfaces/**', 'src/dashboards/**', 'src/reports/**', 'src/datasets/**', 'src/translations/**', 'objectstack.manifest.json'] configuration: - changed-files: - - any-glob-to-any-file: ['*.config.js', '*.config.ts', 'tsconfig.json', '.gitignore', '.env*'] + - any-glob-to-any-file: ['*.config.ts', 'objectstack.config.ts', 'tsconfig.json', '.gitignore', '.changeset/config.json'] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4292003c..dc8d5bbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,12 +37,17 @@ jobs: - name: Check the StackBlitz npm lockfile is in sync run: node scripts/check-stackblitz-lock.mjs + - name: Source hygiene (console.log / TODO / file size) + run: node scripts/check-source-hygiene.mjs + - name: Validate metadata (protocol/schema) run: pnpm run validate - - name: Lint metadata conventions (advisory) + # `objectstack lint` exits 0 on warnings/suggestions and non-zero only on + # real errors — `continue-on-error` was hiding exactly the failures worth + # gating on. + - name: Lint metadata conventions run: pnpm run lint - continue-on-error: true - name: TypeScript type checking run: pnpm run typecheck @@ -50,8 +55,20 @@ jobs: - name: Build project run: pnpm run build - - name: Run tests - run: pnpm test + # `--coverage` so the thresholds in vitest.config.ts actually gate. They + # are set just under the measured numbers for the hook handlers, which + # went from 23% statements / 22% functions to 95% / 95% when the runtime + # suites landed. + - name: Run tests with coverage + run: pnpm run test:coverage + + - name: Upload coverage report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: coverage + path: coverage/ + retention-days: 7 - name: Upload build artifacts if: matrix.node-version == '22.x' diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index f75a8422..a9901001 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -6,6 +6,7 @@ on: paths: - '**.ts' - '**.js' + - '**.mjs' - '**.json' - '**.yml' - '**.yaml' @@ -14,16 +15,16 @@ jobs: quality-check: name: Quality Checks runs-on: ubuntu-latest - + permissions: contents: read - + steps: - name: Checkout code uses: actions/checkout@v7 with: fetch-depth: 0 - + - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -32,39 +33,26 @@ jobs: with: node-version: '22.x' cache: 'pnpm' - + - name: Install dependencies run: pnpm install --frozen-lockfile - - - name: Check for console.log statements - run: | - if grep -r "console\.log" packages/ --include="*.ts" --exclude-dir=node_modules --exclude-dir=__tests__; then - echo "Warning: console.log statements found in source code" - echo "Please use proper logging instead" - # This is just a warning, not failing the build - else - echo "No console.log statements found" - fi - continue-on-error: true - + + # Replaces three inline greps that scanned `packages/` — a directory this + # repo does not have — each with `continue-on-error: true`. The script + # scans the real tree (src, test, e2e, scripts) and fails on a hit; it + # also fails loudly if a scanned directory ever disappears, so the check + # can never silently go vacuous again. + - name: Source hygiene (console.log / TODO / file size) + run: node scripts/check-source-hygiene.mjs + - name: Check TypeScript compilation run: pnpm run typecheck - name: Validate metadata (protocol/schema) run: pnpm run validate - - name: Lint metadata conventions (advisory) + # `objectstack lint` exits 0 on warnings and suggestions and non-zero only + # on real errors, so `continue-on-error` was suppressing nothing but the + # errors we actually want to hear about. + - name: Lint metadata conventions run: pnpm run lint - continue-on-error: true - - - name: Check for TODO comments - run: | - echo "Checking for TODO/FIXME comments..." - grep -r "TODO\|FIXME" packages/ --include="*.ts" --exclude-dir=node_modules || echo "No TODO/FIXME found" - continue-on-error: true - - - name: Check file sizes - run: | - echo "Checking for large files..." - find packages/ -type f -size +100k -exec ls -lh {} \; || echo "No large files found" - continue-on-error: true diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 8354ad7c..88e55200 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -6,8 +6,6 @@ on: paths: - 'docs/**' - 'README.md' - - 'QUICKSTART.md' - - 'PROJECT_SUMMARY.md' workflow_dispatch: jobs: @@ -32,12 +30,15 @@ jobs: - name: Create documentation site run: | + set -euo pipefail mkdir -p _site + # QUICKSTART.md and PROJECT_SUMMARY.md were copied here unguarded and + # have never existed in this repo, so every triggering push failed the + # deploy. `docs/` is copied without `|| true` for the same reason: if + # the documentation tree vanishes we want to hear about it. cp README.md _site/index.md - cp QUICKSTART.md _site/ - cp PROJECT_SUMMARY.md _site/ - cp -r docs/* _site/ 2>/dev/null || true - + cp -r docs/. _site/ + # Create a simple index.html that redirects to README cat > _site/index.html << 'EOF' diff --git a/.github/workflows/docs-app.yml b/.github/workflows/docs-app.yml new file mode 100644 index 00000000..5ebba72d --- /dev/null +++ b/.github/workflows/docs-app.yml @@ -0,0 +1,61 @@ +name: Docs App + +# `apps/docs` is a full Next.js + Fumadocs site (231 mdx pages) with its own +# package.json and its own lockfile — it is NOT part of the root pnpm project, +# so `pnpm install` / `pnpm typecheck` / `pnpm build` at the root have never +# touched a single file of it. A broken import, a bad mdx frontmatter block, or +# a dead internal link could land on main with every check green. +# +# This workflow is the only thing that compiles it. + +on: + push: + branches: [ main, develop ] + paths: + - 'apps/docs/**' + - '.github/workflows/docs-app.yml' + pull_request: + branches: [ main, develop ] + paths: + - 'apps/docs/**' + - '.github/workflows/docs-app.yml' + workflow_dispatch: + +jobs: + build: + name: Typecheck and Build + runs-on: ubuntu-latest + + permissions: + contents: read + + defaults: + run: + working-directory: apps/docs + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22.x' + cache: 'pnpm' + cache-dependency-path: apps/docs/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # `fumadocs-mdx && next typegen && tsc --noEmit` — generates the mdx + # collection types and the route types first, then typechecks against them. + - name: Typecheck + run: pnpm run types:check + + - name: Build + run: pnpm run build + env: + NEXT_TELEMETRY_DISABLED: '1' diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..f80ef824 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,56 @@ +name: E2E + +# The playwright suite had never run in CI. Combined with a `baseURL` pointing +# at a port nothing served and specs that treated 401 as a pass, that meant the +# only tests exercising the real kernel — hooks firing against a real driver +# through the real REST API — were dead code. + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + e2e: + name: Playwright + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22.x' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps chromium + + # playwright.config.ts declares a `webServer` that runs + # `pnpm build && objectstack start -p 4001` and waits on /api/v1/health, + # so there is no separate server step here. The database starts empty and + # the demo seed loads on first boot. + - name: Run end-to-end tests + run: pnpm run test:e2e + + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 7 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bc4a9570..5c6d1560 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,9 @@ pnpm build ### Running Tests ```bash -pnpm test +pnpm test # vitest — metadata contracts + hook/flow runtime suites +pnpm test:e2e # playwright — boots the server and drives the real API +pnpm test:coverage # vitest with a v8 coverage report ``` ### Linting @@ -53,6 +55,19 @@ pnpm test pnpm lint ``` +`pnpm lint` runs `objectstack lint`, which checks **metadata conventions** — +naming, field groups, relationship shapes, i18n coverage. It is not a +JavaScript/TypeScript style linter, and this repo does not use ESLint (a +stray `.eslintrc.json` sat here for a long time with no `eslint` package +installed anywhere to read it). + +Code-level hygiene is enforced separately and does gate CI: + +```bash +pnpm typecheck # tsc over src, test, e2e, and scripts +node scripts/check-source-hygiene.mjs # console.log / TODO / oversized files +``` + ## 🎯 Coding Standards ### TypeScript @@ -153,9 +168,8 @@ Only the compiled `dist/` folder is included in published packages (controlled b ## 🔄 Pull Request Process 1. **Before Creating a PR** - - Ensure all tests pass (`pnpm test`) - - Run type checking (`pnpm typecheck`) - - Run linting and fix any issues (`pnpm lint`) + - Run the full gate (`pnpm verify` — validate, typecheck, lint, hygiene, build, test) + - Run the e2e suite if you touched hooks, flows, or actions (`pnpm test:e2e`) - Update documentation if needed - Add tests for new features - Update ROADMAP.md if the change completes a roadmap item diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 00000000..3751fb6b --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { test as base, expect, type APIRequestContext } from '@playwright/test'; +import { TOKEN_ENV } from './global-setup'; + +/** + * Authenticated e2e fixtures. + * + * The CRM data API is auth-gated: an anonymous `GET /api/v1/data/crm_account` + * returns 401. Both specs used to treat that 401 as a pass — `smoke.spec.ts` + * accepted `[200, 401, 403]` and `opportunity-lifecycle.spec.ts` called + * `test.skip()` on it — so the suite could only ever prove that a route was + * mounted, never that the CRM behaved. And since it also pointed at the wrong + * port, every run took the tolerant branch. + * + * `e2e/global-setup.ts` establishes one session for the whole run; this fixture + * hands each spec a request context carrying that bearer token, so assertions + * can be unconditional. A 401 is now a failure, because it is one. + */ + +export const test = base.extend<{ api: APIRequestContext }>({ + /** + * A request context carrying the session bearer token. Use this for every + * `/api/v1/data/**` call; the built-in unauthenticated `request` fixture + * stays available for asserting that a route *is* gated. + */ + api: async ({ playwright, baseURL }, use) => { + const token = process.env[TOKEN_ENV]; + expect( + token, + `no session token on process.env.${TOKEN_ENV} — e2e/global-setup.ts did not run`, + ).toBeTruthy(); + + const ctx = await playwright.request.newContext({ + baseURL, + extraHTTPHeaders: { Authorization: `Bearer ${token}` }, + }); + await use(ctx); + await ctx.dispose(); + }, +}); + +export { expect } from '@playwright/test'; + +/** Response envelopes the REST API returns for single-record writes/reads. */ +interface RecordEnvelope { + object?: string; + id?: string; + record?: Record; +} + +/** + * Unwrap a single-record response. + * + * The API answers writes with `{ object, id, record }`. The old lifecycle spec + * read `json.data ?? json`, which yields the envelope rather than the record — + * so `rec.probability` was `undefined` and the assertions would have passed + * only by never running. + */ +export function recordOf(body: unknown): Record { + const env = body as RecordEnvelope | null; + if (env && typeof env === 'object' && env.record && typeof env.record === 'object') { + return env.record as Record; + } + return (env ?? {}) as Record; +} + +/** Unwrap a list response (`{ object, records }`). */ +export function recordsOf(body: unknown): Record[] { + const records = (body as { records?: unknown } | null)?.records; + return Array.isArray(records) ? (records as Record[]) : []; +} + +/** + * The id of any seeded account. + * + * `crm_opportunity.crm_account` is required, so an opportunity cannot be + * created without one — the old spec omitted it and would have failed + * validation with a 400 even after authenticating. + */ +export async function seededAccountId(api: APIRequestContext): Promise { + const res = await api.get('/api/v1/data/crm_account?limit=1'); + expect(res.ok(), `could not read seeded accounts: ${res.status()}`).toBeTruthy(); + const [first] = recordsOf(await res.json()); + expect(first, 'no seeded crm_account — the demo seed did not load').toBeTruthy(); + return first.id as string; +} diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 00000000..edd37bbc --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { request, type FullConfig } from '@playwright/test'; + +/** + * One sign-in for the whole run. + * + * The auth plugin rate-limits `/api/v1/auth/*`, so authenticating per spec file + * (let alone per test) trips a 429 and every downstream assertion fails for the + * wrong reason. Playwright spawns workers *after* global setup, so the token + * placed on `process.env` here is inherited by all of them. + * + * The account is created on first run and reused afterwards. It lives in the + * disposable local database (`.objectstack/data`, rebuilt by `pnpm demo:reset`) + * and is not a deployment credential; override with E2E_ADMIN_EMAIL / + * E2E_ADMIN_PASSWORD when pointing the suite at a shared environment. + */ + +export const TOKEN_ENV = 'E2E_SESSION_TOKEN'; + +const EMAIL = process.env.E2E_ADMIN_EMAIL ?? 'e2e-admin@hotcrm.test'; +const PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'e2e-Local-Only-Pw-8842'; +const AUTH_BASE = '/api/v1/auth'; + +function tokenOf(body: unknown): string | undefined { + const token = (body as { token?: unknown } | null)?.token; + return typeof token === 'string' && token.length > 0 ? token : undefined; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** + * Wait until the server is not just listening, but idle enough to serve. + * + * Playwright's `webServer.url` probe goes green the moment `/api/v1/health` + * answers, which on a cold database is *before* the demo seed has finished + * loading. Seeding inserts opportunities, which fire `opportunity_approval`, + * which saturates the event loop for tens of seconds — long enough that the + * very first auth POST timed out. Health returning promptly on consecutive + * polls is a good proxy for "the seed storm has passed". + */ +async function waitForQuiet(ctx: Awaited>): Promise { + const deadline = Date.now() + 180_000; + let consecutiveFast = 0; + while (Date.now() < deadline) { + const started = Date.now(); + const res = await ctx.get('/api/v1/health', { failOnStatusCode: false, timeout: 30_000 }) + .catch(() => undefined); + const elapsed = Date.now() - started; + consecutiveFast = res?.ok() && elapsed < 1_000 ? consecutiveFast + 1 : 0; + if (consecutiveFast >= 3) return; + await sleep(1_000); + } + throw new Error('server never went quiet — the demo seed may be stuck (see webServer output)'); +} + +export default async function globalSetup(config: FullConfig): Promise { + const baseURL = + config.projects[0]?.use?.baseURL ?? process.env.HOTCRM_BASE_URL ?? 'http://localhost:4001'; + // Generous per-request timeout: this runs while the server may still be + // finishing its first-boot work. + const ctx = await request.newContext({ baseURL, timeout: 120_000 }); + + try { + await waitForQuiet(ctx); + + // Sign-up first; on every run after the first it legitimately rejects + // because the account already exists, and we fall through to sign-in. + const signUp = await ctx.post(`${AUTH_BASE}/sign-up/email`, { + data: { email: EMAIL, password: PASSWORD, name: 'E2E Admin' }, + failOnStatusCode: false, + }); + let token = signUp.ok() ? tokenOf(await signUp.json()) : undefined; + + // The auth plugin rate-limits; a 429 here is transient, so back off rather + // than failing the whole run. + for (let attempt = 0; !token && attempt < 5; attempt++) { + if (attempt > 0) await sleep(2_000 * attempt); + const signIn = await ctx.post(`${AUTH_BASE}/sign-in/email`, { + data: { email: EMAIL, password: PASSWORD }, + failOnStatusCode: false, + }); + if (signIn.ok()) { + token = tokenOf(await signIn.json()); + continue; + } + if (signIn.status() !== 429 || attempt === 4) { + throw new Error( + `e2e sign-in failed (${signIn.status()}): ${await signIn.text()}\n` + + 'The suite authenticates for real — it no longer accepts 401 as a pass. ' + + 'If this is a shared environment, set E2E_ADMIN_EMAIL / E2E_ADMIN_PASSWORD.', + ); + } + } + + if (!token) throw new Error('auth succeeded but returned no session token'); + process.env[TOKEN_ENV] = token; + } finally { + await ctx.dispose(); + } +} diff --git a/e2e/opportunity-lifecycle.spec.ts b/e2e/opportunity-lifecycle.spec.ts index 3dc196d7..e6d01b72 100644 --- a/e2e/opportunity-lifecycle.spec.ts +++ b/e2e/opportunity-lifecycle.spec.ts @@ -1,70 +1,176 @@ -import { test, expect, type APIRequestContext } from '@playwright/test'; +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { test, expect, recordOf, seededAccountId } from './fixtures'; +import type { APIRequestContext } from '@playwright/test'; /** * Opportunity lifecycle regression tests. * - * These exercise the `opportunity_lifecycle` hook (src/objects/opportunity.hook.ts) - * end-to-end through the REST API, since L2 hooks are body-only sandboxed code - * and cannot be imported and unit-tested in isolation. The hook is the single - * source of truth for `probability` and `expected_revenue` (the duplicate - * field-update workflows were removed), so these assertions guard against drift. + * These exercise `opportunity_lifecycle` (src/objects/opportunity.hook.ts) end + * to end through the REST API. L2 hooks are body-only sandboxed code, so the + * vitest suite can only call `handler(ctx)` with a hand-built ctx — this file is + * what proves the hook is actually bound and firing inside the real kernel, + * against the real driver, with the real closed-record freeze guard in play. + * + * The hook is the single source of truth for `probability`, `expected_revenue` + * and `forecast_category` (the duplicate field-update workflows were removed), + * so these assertions guard against drift. * - * The CRM data API may be auth-gated depending on how the server is launched. - * When a write is rejected (401/403), the test self-skips rather than failing — - * mirroring the tolerance already used in e2e/smoke.spec.ts. + * Every assertion is unconditional. The previous version self-skipped on + * 401/403, read the record out of `json.data` when the API answers + * `{ object, id, record }`, and omitted the REQUIRED `crm_account` — three + * independent reasons it could never have gone green on real behaviour. */ const BASE = '/api/v1/data/crm_opportunity'; async function createOpportunity( - request: APIRequestContext, + api: APIRequestContext, body: Record, -): Promise<{ skipped: boolean; status: number; data?: Record }> { - const res = await request.post(BASE, { data: body }); - if ([401, 403].includes(res.status())) return { skipped: true, status: res.status() }; +): Promise> { + const res = await api.post(BASE, { data: body }); expect(res.ok(), `create failed: ${res.status()} ${await res.text()}`).toBeTruthy(); - const json = await res.json(); - return { skipped: false, status: res.status(), data: json.data ?? json }; + return recordOf(await res.json()); +} + +async function patchOpportunity( + api: APIRequestContext, + id: string, + body: Record, +): Promise> { + const res = await api.patch(`${BASE}/${id}`, { data: body }); + expect(res.ok(), `update failed: ${res.status()} ${await res.text()}`).toBeTruthy(); + return recordOf(await res.json()); } -test('expected_revenue and probability are derived from stage on create', async ({ request }) => { - const created = await createOpportunity(request, { - name: 'E2E Pipeline Deal', - stage: 'proposal', // probability 60 - amount: 10000, - close_date: '2099-12-31', +test.describe('opportunity_lifecycle hook (through the real kernel)', () => { + let accountId: string | undefined; + const created: string[] = []; + + test.beforeEach(async ({ api }) => { + accountId ??= await seededAccountId(api); }); - test.skip(created.skipped, `data API is auth-gated (status ${created.status})`); - const rec = created.data!; - // Hook syncs probability to the stage's canonical value … - expect(rec.probability).toBe(60); - // … and recomputes expected_revenue = amount * probability/100. - expect(rec.expected_revenue).toBe(6000); + test.afterEach(async ({ api }) => { + // Best-effort cleanup; a closed opportunity may refuse edits but deletes + // are not blocked by the freeze guard. + while (created.length) { + const id = created.pop()!; + await api.delete(`${BASE}/${id}`).catch(() => undefined); + } + }); - // cleanup (best-effort) - if (rec.id) await request.delete(`${BASE}/${rec.id}`); -}); + test('derives probability and expected_revenue from stage on create', async ({ api }) => { + const rec = await createOpportunity(api, { + name: 'E2E Pipeline Deal', + stage: 'proposal', // canonical probability 60 + amount: 10_000, + close_date: '2099-12-31', + crm_account: accountId, + }); + created.push(rec.id as string); -test('closed_won stamps close_date and recomputes to 100% probability', async ({ request }) => { - const created = await createOpportunity(request, { - name: 'E2E Won Deal', - stage: 'negotiation', - amount: 25000, - close_date: '2099-12-31', + // Hook syncs probability to the stage's canonical value … + expect(rec.probability).toBe(60); + // … recomputes expected_revenue = amount × probability/100 … + expect(rec.expected_revenue).toBe(6_000); + // … and stamps the matching forecast category. + expect(rec.forecast_category).toBe('commit'); }); - test.skip(created.skipped, `data API is auth-gated (status ${created.status})`); - const id = created.data!.id as string; - const res = await request.patch(`${BASE}/${id}`, { data: { stage: 'closed_won' } }); - expect(res.ok(), `update failed: ${res.status()} ${await res.text()}`).toBeTruthy(); - const updated = (await res.json()).data ?? (await res.json()); + test('closed_won stamps close_date and recomputes to 100% probability', async ({ api }) => { + const rec = await createOpportunity(api, { + name: 'E2E Won Deal', + stage: 'negotiation', + amount: 25_000, + close_date: '2099-12-31', + crm_account: accountId, + }); + const id = rec.id as string; + created.push(id); + + const updated = await patchOpportunity(api, id, { stage: 'closed_won' }); - expect(updated.stage).toBe('closed_won'); - expect(updated.probability).toBe(100); - expect(updated.expected_revenue).toBe(25000); - // Hook stamps close_date to today when entering closed_won. - expect(typeof updated.close_date).toBe('string'); + expect(updated.stage).toBe('closed_won'); + expect(updated.probability).toBe(100); + expect(updated.expected_revenue).toBe(25_000); + expect(updated.forecast_category).toBe('closed'); + // The hook stamps close_date to today on entering closed_won, so the far + // future date supplied at create time must have been overwritten. + expect(updated.close_date).toMatch(/^\d{4}-\d{2}-\d{2}/); + expect(updated.close_date).not.toBe('2099-12-31'); + // Advancing the stage restarts the stall clock. `days_in_stage` is a + // FORMULA over `stage_entry_date` (#489), so the stored column is what the + // hook writes and what the stagnation sweep filters on. + expect(updated.stage_entry_date).toMatch(/^\d{4}-\d{2}-\d{2}/); - if (id) await request.delete(`${BASE}/${id}`); + // Formulas are evaluated over QUERY results, not echoed on the write + // response, so re-read to prove the derived value actually resolves. + const reread = await api.get(`${BASE}/${id}`); + expect(reread.ok(), `re-read failed: ${reread.status()}`).toBeTruthy(); + const fresh = recordOf(await reread.json()); + expect(fresh.stage_entry_date).toBe(updated.stage_entry_date); + expect(fresh.days_in_stage, 'the formula should read 0 the day the stage changed').toBe(0); + }); + + test('closed_lost drops probability to 0 and omits the deal from forecast', async ({ api }) => { + const rec = await createOpportunity(api, { + name: 'E2E Lost Deal', + stage: 'negotiation', + amount: 40_000, + close_date: '2099-12-31', + crm_account: accountId, + }); + const id = rec.id as string; + created.push(id); + + const updated = await patchOpportunity(api, id, { stage: 'closed_lost' }); + + expect(updated.stage).toBe('closed_lost'); + expect(updated.probability).toBe(0); + expect(updated.expected_revenue).toBe(0); + expect(updated.forecast_category).toBe('omitted'); + }); + + test('changing amount re-derives expected_revenue at the current stage', async ({ api }) => { + const rec = await createOpportunity(api, { + name: 'E2E Amount Change', + stage: 'proposal', // 60% + amount: 10_000, + close_date: '2099-12-31', + crm_account: accountId, + }); + const id = rec.id as string; + created.push(id); + expect(rec.expected_revenue).toBe(6_000); + + const updated = await patchOpportunity(api, id, { amount: 50_000 }); + expect(updated.amount).toBe(50_000); + expect(updated.expected_revenue).toBe(30_000); // 50k × 60% + }); + + test('a closed opportunity rejects business-field edits but accepts narrative ones', async ({ api }) => { + const rec = await createOpportunity(api, { + name: 'E2E Frozen Deal', + stage: 'negotiation', + amount: 15_000, + close_date: '2099-12-31', + crm_account: accountId, + }); + const id = rec.id as string; + created.push(id); + await patchOpportunity(api, id, { stage: 'closed_won' }); + + // Business field — the freeze guard must reject it. + const rejected = await api.patch(`${BASE}/${id}`, { data: { amount: 999 } }); + expect( + rejected.ok(), + 'a closed opportunity accepted an amount edit — the freeze guard is not firing', + ).toBeFalsy(); + + // Narrative field — explicitly allowed while closed. + const allowed = await patchOpportunity(api, id, { next_step: 'Kick off onboarding' }); + expect(allowed.next_step).toBe('Kick off onboarding'); + expect(allowed.amount).toBe(15_000); // untouched by the rejected write + }); }); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index de010f61..4cd0411b 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,4 +1,10 @@ -import { test, expect } from '@playwright/test'; +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { test, expect, recordsOf } from './fixtures'; + +/** + * Server smoke tests — the routes are mounted and the CRM data is reachable. + */ test('health endpoint reports ok', async ({ request }) => { const res = await request.get('/api/v1/health'); @@ -6,7 +12,13 @@ test('health endpoint reports ok', async ({ request }) => { const body = await res.json(); expect(body.success).toBe(true); expect(body.data.status).toBe('ok'); - expect(body.data.version).toBe('1.0.0'); + // `data.version` is the ObjectStack *health payload* schema version, not + // hotcrm's. Pinning it to the literal '1.0.0' asserted a platform-owned + // constant that no change to this repo can affect and that any platform bump + // would break. Assert the shape instead; hotcrm's own version is pinned in + // test/smoke.test.ts against the manifest, where it means something. + expect(body.data.version, 'health version should be a semver string').toMatch(/^\d+\.\d+\.\d+/); + expect(typeof body.data.uptime).toBe('number'); }); // ObjectStack 7.x serves the unified UI shell (Studio + apps) under @@ -26,8 +38,33 @@ test('login page loads', async ({ page }) => { expect(html.length).toBeGreaterThan(100); }); -test('REST API exposes hotcrm objects', async ({ request }) => { +test('the CRM data API is auth-gated', async ({ request }) => { + // Unauthenticated, on purpose: this is the assertion the old spec *thought* + // it was making when it accepted `[200, 401, 403]` for an anonymous read. + // Accepting 200 there meant a regression that exposed every account + // anonymously would have kept the suite green. const res = await request.get('/api/v1/data/crm_account?limit=1'); - // 200 (seeded/public) or 401/403 (auth-gated) — both prove the route is mounted. - expect([200, 401, 403]).toContain(res.status()); + expect([401, 403]).toContain(res.status()); +}); + +test('REST API serves seeded hotcrm records to an authenticated caller', async ({ api }) => { + const res = await api.get('/api/v1/data/crm_account?limit=5'); + expect(res.ok(), `authenticated read failed: ${res.status()} ${await res.text()}`).toBeTruthy(); + + const records = recordsOf(await res.json()); + expect(records.length, 'no seeded accounts returned').toBeGreaterThan(0); + // A real record, not just a 200 with an empty envelope. + expect(typeof records[0].id).toBe('string'); + expect(typeof records[0].name).toBe('string'); +}); + +test('every core CRM object is queryable', async ({ api }) => { + const objects = [ + 'crm_account', 'crm_contact', 'crm_lead', 'crm_opportunity', + 'crm_case', 'crm_campaign', 'crm_quote', 'crm_task', 'crm_product', + ]; + for (const name of objects) { + const res = await api.get(`/api/v1/data/${name}?limit=1`); + expect(res.ok(), `${name} is not queryable: ${res.status()}`).toBeTruthy(); + } }); diff --git a/package-lock.json b/package-lock.json index b757fbb4..02f1813d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,8 @@ "devDependencies": { "@changesets/cli": "^2.31.1", "@playwright/test": "^1.61.1", + "@vitest/coverage-v8": "^4.1.10", + "tsx": "^4.23.1", "typescript": "^7.0.2", "vitest": "^4.1.9" }, @@ -58,6 +60,42 @@ "node": ">=0.6.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -68,6 +106,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@better-auth/core": { "version": "1.7.0-rc.1", "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.7.0-rc.1.tgz", @@ -2391,13 +2453,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", - "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.62.0" + "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" @@ -3087,6 +3149,37 @@ "node": ">=16.20.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -3493,6 +3586,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -5034,6 +5139,13 @@ "node": ">=16.9.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -5313,6 +5425,58 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -5339,6 +5503,13 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", @@ -5987,6 +6158,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6621,13 +6820,13 @@ } }, "node_modules/playwright": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", - "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.0" + "playwright-core": "1.62.1" }, "bin": { "playwright": "cli.js" @@ -6640,9 +6839,9 @@ } }, "node_modules/playwright-core": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", - "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7859,21 +8058,21 @@ } }, "node_modules/tldts": { - "version": "7.4.9", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", - "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", "license": "MIT", "dependencies": { - "tldts-core": "^7.4.9" + "tldts-core": "^7.4.10" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.9", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", - "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", "license": "MIT" }, "node_modules/tmp": { diff --git a/package.json b/package.json index 4451b83a..dd06726a 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,11 @@ "validate": "objectstack validate", "lint": "objectstack lint --skip-i18n", "test": "vitest run", - "test:qa": "objectstack test", + "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", - "verify": "pnpm validate && pnpm typecheck && pnpm build && pnpm test", + "hygiene": "node scripts/check-source-hygiene.mjs", + "reconcile:analytics": "tsx scripts/analytics-reconcile/run.ts", + "verify": "pnpm validate && pnpm typecheck && pnpm lint && pnpm hygiene && pnpm build && pnpm test", "changeset": "changeset", "changeset:version": "changeset version", "changeset:status": "changeset status --since=origin/main", @@ -48,6 +50,8 @@ "devDependencies": { "@changesets/cli": "^2.31.1", "@playwright/test": "^1.61.1", + "@vitest/coverage-v8": "^4.1.10", + "tsx": "^4.23.1", "typescript": "^7.0.2", "vitest": "^4.1.9" }, diff --git a/playwright.config.ts b/playwright.config.ts index a9bd35f8..c25de629 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,17 +1,66 @@ import { defineConfig, devices } from '@playwright/test'; +/** + * Playwright config for the hotcrm end-to-end suite. + * + * Previously this pointed `baseURL` at port 4004 while `pnpm dev` / `pnpm start` + * (and every doc) served 4001, and declared no `webServer`, so every spec hit a + * closed port. Nothing noticed because no workflow ran playwright and `verify` + * did not include it — the suite had been unrunnable for its entire life. + */ + +const PORT = Number(process.env.HOTCRM_PORT ?? 4001); +export const BASE_URL = process.env.HOTCRM_BASE_URL ?? `http://localhost:${PORT}`; + +const isCI = !!process.env.CI; + +/** + * Some sandboxes and prebuilt CI images ship a Chromium that does not match the + * revision `@playwright/test` would download. Point this at that binary rather + * than re-downloading; unset, Playwright resolves its own browser as usual. + */ +const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || undefined; + export default defineConfig({ testDir: './e2e', + globalSetup: './e2e/global-setup.ts', timeout: 30_000, + expect: { timeout: 10_000 }, fullyParallel: false, - retries: 0, - reporter: [['list']], + // The specs write real records through the REST API; a single worker keeps + // the assertions on seeded aggregates deterministic. + workers: 1, + forbidOnly: isCI, + // A retry buys the trace below. Locally, failing fast is more useful. + retries: isCI ? 2 : 0, + reporter: isCI + ? [['github'], ['list'], ['html', { open: 'never' }]] + : [['list']], use: { - baseURL: 'http://localhost:4004', + baseURL: BASE_URL, headless: true, - trace: 'off', + // `off` meant a CI failure produced a bare assertion message and nothing to + // debug from. The retry above makes the first failure cheap to re-run with + // a full trace attached. + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', }, projects: [ - { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], launchOptions: { executablePath } }, + }, ], + // `objectstack start` serves the built artifact, so the build has to run + // first. Reusing an already-running dev server locally keeps the edit/run + // loop fast; CI always gets a cold, freshly built server. + webServer: { + command: `pnpm run build && pnpm exec objectstack start -p ${PORT}`, + url: `${BASE_URL}/api/v1/health`, + reuseExistingServer: !isCI, + timeout: 180_000, + stdout: 'pipe', + stderr: 'pipe', + }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21cebde3..b261d7a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,10 +10,10 @@ importers: dependencies: '@objectstack/account': specifier: 16.1.0 - version: 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + version: 16.1.0(vitest@4.1.10) '@objectstack/cli': specifier: 16.1.0 - version: 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + version: 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10) '@objectstack/driver-memory': specifier: 16.1.0 version: 16.1.0 @@ -25,13 +25,13 @@ importers: version: 16.1.0(better-sqlite3@12.11.1) '@objectstack/metadata': specifier: 16.1.0 - version: 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + version: 16.1.0(vitest@4.1.10) '@objectstack/objectql': specifier: 16.1.0 - version: 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + version: 16.1.0(vitest@4.1.10) '@objectstack/runtime': specifier: 16.1.0 - version: 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + version: 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10) '@objectstack/service-analytics': specifier: 16.1.0 version: 16.1.0 @@ -48,12 +48,18 @@ importers: '@playwright/test': specifier: ^1.61.1 version: 1.62.0 + '@vitest/coverage-v8': + specifier: ^4.1.10 + version: 4.1.10(vitest@4.1.10) + tsx: + specifier: ^4.23.1 + version: 4.23.1 typescript: specifier: ^7.0.2 version: 7.0.2 vitest: specifier: ^4.1.9 - version: 4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0) + version: 4.1.10(@vitest/coverage-v8@4.1.10)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0) optionalDependencies: better-sqlite3: specifier: ^12.11.1 @@ -65,10 +71,31 @@ packages: resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==} engines: {node: '>=12'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@better-auth/core@1.7.0-rc.1': resolution: {integrity: sha512-qwVJ5LBPAp8FhhRbUzJDv8cp6h6sn266PPJIXBhB2aYXee40/VGpyoTmx9+LmOotOLQ4BWSO5rCSDvS8FGVdEQ==} peerDependencies: @@ -999,6 +1026,15 @@ packages: cpu: [x64] os: [win32] + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -1101,6 +1137,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -1679,6 +1718,9 @@ packages: resolution: {integrity: sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==} engines: {node: '>=16.9.0'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -1776,6 +1818,18 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jake@10.9.4: resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} engines: {node: '>=10'} @@ -1784,6 +1838,9 @@ packages: jose@6.2.4: resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-yaml@3.15.0: resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} hasBin: true @@ -1998,6 +2055,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2516,6 +2580,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + supports-color@8.1.1: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} @@ -2818,8 +2886,23 @@ snapshots: escape-html: 1.0.3 xpath: 0.0.32 + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + '@better-auth/core@1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)': dependencies: '@better-auth/utils': 0.4.2 @@ -2856,12 +2939,12 @@ snapshots: optionalDependencies: mongodb: 7.5.0 - '@better-auth/oauth-provider@1.7.0-rc.1(@better-auth/core@1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)))(better-call@1.3.7(zod@4.4.3))': + '@better-auth/oauth-provider@1.7.0-rc.1(@better-auth/core@1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10))(better-call@1.3.7(zod@4.4.3))': dependencies: '@better-auth/core': 1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: 1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + better-auth: 1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10) better-call: 1.3.7(zod@4.4.3) jose: 6.2.4 zod: 4.4.3 @@ -2871,20 +2954,20 @@ snapshots: '@better-auth/core': 1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 - '@better-auth/scim@1.7.0-rc.1(@better-auth/core@1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(better-auth@1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)))(better-call@1.3.7(zod@4.4.3))': + '@better-auth/scim@1.7.0-rc.1(@better-auth/core@1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(better-auth@1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10))(better-call@1.3.7(zod@4.4.3))': dependencies: '@better-auth/core': 1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 - better-auth: 1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + better-auth: 1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10) better-call: 1.3.7(zod@4.4.3) zod: 4.4.3 - '@better-auth/sso@1.7.0-rc.1(@better-auth/core@1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)))(better-call@1.3.7(zod@4.4.3))': + '@better-auth/sso@1.7.0-rc.1(@better-auth/core@1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10))(better-call@1.3.7(zod@4.4.3))': dependencies: '@better-auth/core': 1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: 1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + better-auth: 1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10) better-call: 1.3.7(zod@4.4.3) fast-xml-parser: 5.10.1 jose: 6.2.4 @@ -3274,19 +3357,19 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@objectstack/account@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/account@16.1.0(vitest@4.1.10)': dependencies: - '@objectstack/platform-objects': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/platform-objects': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 transitivePeerDependencies: - ai - vitest - '@objectstack/cli@16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/cli@16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10)': dependencies: - '@objectstack/account': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/account': 16.1.0(vitest@4.1.10) '@objectstack/client': 16.1.0 - '@objectstack/cloud-connection': 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/cloud-connection': 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10) '@objectstack/console': 16.1.0 '@objectstack/core': 16.1.0 '@objectstack/driver-memory': 16.1.0 @@ -3296,41 +3379,41 @@ snapshots: '@objectstack/formula': 16.1.0 '@objectstack/lint': 16.1.0 '@objectstack/mcp': 16.1.0 - '@objectstack/metadata': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/objectql': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/metadata': 16.1.0(vitest@4.1.10) + '@objectstack/objectql': 16.1.0(vitest@4.1.10) '@objectstack/observability': 16.1.0 - '@objectstack/platform-objects': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/plugin-approvals': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/plugin-audit': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/plugin-auth': 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/plugin-email': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/platform-objects': 16.1.0(vitest@4.1.10) + '@objectstack/plugin-approvals': 16.1.0(vitest@4.1.10) + '@objectstack/plugin-audit': 16.1.0(vitest@4.1.10) + '@objectstack/plugin-auth': 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10) + '@objectstack/plugin-email': 16.1.0(vitest@4.1.10) '@objectstack/plugin-hono-server': 16.1.0 - '@objectstack/plugin-pinyin-search': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/plugin-reports': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/plugin-security': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/plugin-sharing': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/plugin-pinyin-search': 16.1.0(vitest@4.1.10) + '@objectstack/plugin-reports': 16.1.0(vitest@4.1.10) + '@objectstack/plugin-security': 16.1.0(vitest@4.1.10) + '@objectstack/plugin-sharing': 16.1.0(vitest@4.1.10) '@objectstack/plugin-webhooks': 16.1.0 - '@objectstack/rest': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/runtime': 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/rest': 16.1.0(vitest@4.1.10) + '@objectstack/runtime': 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10) '@objectstack/service-analytics': 16.1.0 '@objectstack/service-automation': 16.1.0 '@objectstack/service-cache': 16.1.0 '@objectstack/service-datasource': 16.1.0 - '@objectstack/service-job': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/service-job': 16.1.0(vitest@4.1.10) '@objectstack/service-messaging': 16.1.0 - '@objectstack/service-package': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/service-queue': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/service-realtime': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/service-settings': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/service-package': 16.1.0(vitest@4.1.10) + '@objectstack/service-queue': 16.1.0(vitest@4.1.10) + '@objectstack/service-realtime': 16.1.0(vitest@4.1.10) + '@objectstack/service-settings': 16.1.0(vitest@4.1.10) '@objectstack/service-sms': 16.1.0 - '@objectstack/service-storage': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/setup': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/service-storage': 16.1.0(vitest@4.1.10) + '@objectstack/setup': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 '@objectstack/trigger-api': 16.1.0 '@objectstack/trigger-record-change': 16.1.0 '@objectstack/trigger-schedule': 16.1.0 '@objectstack/types': 16.1.0 - '@objectstack/verify': 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/verify': 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10) '@oclif/core': 4.13.1 bundle-require: 5.1.0(esbuild@0.28.1) chalk: 5.6.2 @@ -3394,10 +3477,10 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/cloud-connection@16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/cloud-connection@16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 - '@objectstack/runtime': 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/runtime': 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10) '@objectstack/spec': 16.1.0 '@objectstack/types': 16.1.0 transitivePeerDependencies: @@ -3546,28 +3629,28 @@ snapshots: - ai - supports-color - '@objectstack/metadata-core@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/metadata-core@16.1.0(vitest@4.1.10)': dependencies: '@objectstack/spec': 16.1.0 zod: 4.4.3 optionalDependencies: - vitest: 4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0) + vitest: 4.1.10(@vitest/coverage-v8@4.1.10)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0) transitivePeerDependencies: - ai - '@objectstack/metadata-fs@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/metadata-fs@16.1.0(vitest@4.1.10)': dependencies: - '@objectstack/metadata-core': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/metadata-core': 16.1.0(vitest@4.1.10) chokidar: 5.0.0 transitivePeerDependencies: - ai - vitest - '@objectstack/metadata-protocol@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/metadata-protocol@16.1.0(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 '@objectstack/formula': 16.1.0 - '@objectstack/metadata-core': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/metadata-core': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 '@objectstack/types': 16.1.0 zod: 4.4.3 @@ -3575,12 +3658,12 @@ snapshots: - ai - vitest - '@objectstack/metadata@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/metadata@16.1.0(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 - '@objectstack/metadata-core': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/metadata-fs': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/platform-objects': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/metadata-core': 16.1.0(vitest@4.1.10) + '@objectstack/metadata-fs': 16.1.0(vitest@4.1.10) + '@objectstack/platform-objects': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 '@objectstack/types': 16.1.0 chokidar: 5.0.0 @@ -3591,12 +3674,12 @@ snapshots: - ai - vitest - '@objectstack/objectql@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/objectql@16.1.0(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 '@objectstack/formula': 16.1.0 - '@objectstack/metadata-core': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/metadata-protocol': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/metadata-core': 16.1.0(vitest@4.1.10) + '@objectstack/metadata-protocol': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 '@objectstack/types': 16.1.0 ajv: 8.20.0 @@ -3611,47 +3694,47 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/platform-objects@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/platform-objects@16.1.0(vitest@4.1.10)': dependencies: - '@objectstack/metadata-core': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/metadata-core': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 transitivePeerDependencies: - ai - vitest - '@objectstack/plugin-approvals@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/plugin-approvals@16.1.0(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 '@objectstack/formula': 16.1.0 - '@objectstack/metadata-core': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/platform-objects': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/metadata-core': 16.1.0(vitest@4.1.10) + '@objectstack/platform-objects': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 transitivePeerDependencies: - ai - vitest - '@objectstack/plugin-audit@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/plugin-audit@16.1.0(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 - '@objectstack/platform-objects': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/platform-objects': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 transitivePeerDependencies: - ai - vitest - '@objectstack/plugin-auth@16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/plugin-auth@16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10)': dependencies: '@better-auth/core': 1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) - '@better-auth/oauth-provider': 1.7.0-rc.1(@better-auth/core@1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)))(better-call@1.3.7(zod@4.4.3)) - '@better-auth/scim': 1.7.0-rc.1(@better-auth/core@1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(better-auth@1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)))(better-call@1.3.7(zod@4.4.3)) - '@better-auth/sso': 1.7.0-rc.1(@better-auth/core@1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)))(better-call@1.3.7(zod@4.4.3)) + '@better-auth/oauth-provider': 1.7.0-rc.1(@better-auth/core@1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10))(better-call@1.3.7(zod@4.4.3)) + '@better-auth/scim': 1.7.0-rc.1(@better-auth/core@1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(better-auth@1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10))(better-call@1.3.7(zod@4.4.3)) + '@better-auth/sso': 1.7.0-rc.1(@better-auth/core@1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10))(better-call@1.3.7(zod@4.4.3)) '@noble/hashes': 2.2.0 '@objectstack/core': 16.1.0 - '@objectstack/platform-objects': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/rest': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/platform-objects': 16.1.0(vitest@4.1.10) + '@objectstack/rest': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 '@objectstack/types': 16.1.0 - better-auth: 1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + better-auth: 1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10) jose: 6.2.4 transitivePeerDependencies: - '@better-auth/utils' @@ -3682,11 +3765,11 @@ snapshots: - vitest - vue - '@objectstack/plugin-email@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/plugin-email@16.1.0(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 '@objectstack/formula': 16.1.0 - '@objectstack/platform-objects': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/platform-objects': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 transitivePeerDependencies: - ai @@ -3703,42 +3786,42 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/plugin-pinyin-search@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/plugin-pinyin-search@16.1.0(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 - '@objectstack/objectql': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/objectql': 16.1.0(vitest@4.1.10) '@objectstack/types': 16.1.0 pinyin-pro: 3.28.2 transitivePeerDependencies: - ai - vitest - '@objectstack/plugin-reports@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/plugin-reports@16.1.0(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 - '@objectstack/platform-objects': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/platform-objects': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 croner: 10.0.1 transitivePeerDependencies: - ai - vitest - '@objectstack/plugin-security@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/plugin-security@16.1.0(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 '@objectstack/formula': 16.1.0 - '@objectstack/platform-objects': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/platform-objects': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 transitivePeerDependencies: - ai - vitest - '@objectstack/plugin-sharing@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/plugin-sharing@16.1.0(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 '@objectstack/formula': 16.1.0 - '@objectstack/objectql': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/platform-objects': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/objectql': 16.1.0(vitest@4.1.10) + '@objectstack/platform-objects': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 transitivePeerDependencies: - ai @@ -3752,12 +3835,12 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/rest@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/rest@16.1.0(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 '@objectstack/observability': 16.1.0 - '@objectstack/platform-objects': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/service-package': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/platform-objects': 16.1.0(vitest@4.1.10) + '@objectstack/service-package': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 '@objectstack/types': 16.1.0 exceljs: 4.4.0 @@ -3766,20 +3849,20 @@ snapshots: - ai - vitest - '@objectstack/runtime@16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/runtime@16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 '@objectstack/driver-memory': 16.1.0 '@objectstack/driver-sql': 16.1.0 '@objectstack/driver-sqlite-wasm': 16.1.0(better-sqlite3@12.11.1) '@objectstack/formula': 16.1.0 - '@objectstack/metadata': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/metadata-core': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/objectql': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/metadata': 16.1.0(vitest@4.1.10) + '@objectstack/metadata-core': 16.1.0(vitest@4.1.10) + '@objectstack/objectql': 16.1.0(vitest@4.1.10) '@objectstack/observability': 16.1.0 - '@objectstack/plugin-auth': 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/plugin-security': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/rest': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/plugin-auth': 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10) + '@objectstack/plugin-security': 16.1.0(vitest@4.1.10) + '@objectstack/rest': 16.1.0(vitest@4.1.10) '@objectstack/service-cluster': 16.1.0 '@objectstack/service-datasource': 16.1.0 '@objectstack/service-i18n': 16.1.0 @@ -3878,10 +3961,10 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/service-job@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/service-job@16.1.0(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 - '@objectstack/platform-objects': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/platform-objects': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 croner: 10.0.1 transitivePeerDependencies: @@ -3895,38 +3978,38 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/service-package@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/service-package@16.1.0(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 - '@objectstack/metadata-core': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/metadata-core': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 transitivePeerDependencies: - ai - vitest - '@objectstack/service-queue@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/service-queue@16.1.0(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 - '@objectstack/platform-objects': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/platform-objects': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 transitivePeerDependencies: - ai - vitest - '@objectstack/service-realtime@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/service-realtime@16.1.0(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 - '@objectstack/platform-objects': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/platform-objects': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 transitivePeerDependencies: - ai - vitest - '@objectstack/service-settings@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/service-settings@16.1.0(vitest@4.1.10)': dependencies: '@noble/ciphers': 2.2.0 '@objectstack/core': 16.1.0 - '@objectstack/platform-objects': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/platform-objects': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 '@objectstack/types': 16.1.0 transitivePeerDependencies: @@ -3940,19 +4023,19 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/service-storage@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/service-storage@16.1.0(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 '@objectstack/observability': 16.1.0 - '@objectstack/platform-objects': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/platform-objects': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 transitivePeerDependencies: - ai - vitest - '@objectstack/setup@16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/setup@16.1.0(vitest@4.1.10)': dependencies: - '@objectstack/platform-objects': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/platform-objects': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 transitivePeerDependencies: - ai @@ -3989,21 +4072,21 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/verify@16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@objectstack/verify@16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10)': dependencies: '@objectstack/core': 16.1.0 '@objectstack/driver-sqlite-wasm': 16.1.0(better-sqlite3@12.11.1) - '@objectstack/objectql': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/plugin-auth': 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/objectql': 16.1.0(vitest@4.1.10) + '@objectstack/plugin-auth': 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10) '@objectstack/plugin-hono-server': 16.1.0 - '@objectstack/plugin-security': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/plugin-sharing': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/rest': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) - '@objectstack/runtime': 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/plugin-security': 16.1.0(vitest@4.1.10) + '@objectstack/plugin-sharing': 16.1.0(vitest@4.1.10) + '@objectstack/rest': 16.1.0(vitest@4.1.10) + '@objectstack/runtime': 16.1.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(better-sqlite3@12.11.1)(kysely@0.29.4)(mongodb@7.5.0)(nanostores@1.4.1)(vitest@4.1.10) '@objectstack/service-analytics': 16.1.0 '@objectstack/service-automation': 16.1.0 '@objectstack/service-datasource': 16.1.0 - '@objectstack/service-settings': 16.1.0(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@objectstack/service-settings': 16.1.0(vitest@4.1.10) '@objectstack/spec': 16.1.0 transitivePeerDependencies: - '@aws-sdk/credential-providers' @@ -4220,6 +4303,20 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@vitest/coverage-v8@4.1.10)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0) + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -4349,6 +4446,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async@3.2.6: {} balanced-match@1.0.2: {} @@ -4357,7 +4460,7 @@ snapshots: base64-js@1.5.1: {} - better-auth@1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)): + better-auth@1.7.0-rc.1(better-sqlite3@12.11.1)(mongodb@7.5.0)(vitest@4.1.10): dependencies: '@better-auth/core': 1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/drizzle-adapter': 1.7.0-rc.1(@better-auth/core@1.7.0-rc.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) @@ -4379,7 +4482,7 @@ snapshots: optionalDependencies: better-sqlite3: 12.11.1 mongodb: 7.5.0 - vitest: 4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0) + vitest: 4.1.10(@vitest/coverage-v8@4.1.10)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -4920,6 +5023,8 @@ snapshots: hono@4.12.32: {} + html-escaper@2.0.2: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -4992,6 +5097,19 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jake@10.9.4: dependencies: async: 3.2.6 @@ -5000,6 +5118,8 @@ snapshots: jose@6.2.4: {} + js-tokens@10.0.0: {} + js-yaml@3.15.0: dependencies: argparse: 1.0.10 @@ -5156,6 +5276,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + math-intrinsics@1.1.0: {} media-typer@1.1.1: {} @@ -5662,6 +5792,10 @@ snapshots: tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + supports-color@8.1.1: dependencies: has-flag: 4.0.0 @@ -5819,7 +5953,7 @@ snapshots: tsx: 4.23.1 yaml: 2.9.0 - vitest@4.1.10(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0): + vitest@4.1.10(@vitest/coverage-v8@4.1.10)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.5(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) @@ -5841,6 +5975,8 @@ snapshots: tinyrainbow: 3.1.0 vite: 8.1.5(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0) why-is-node-running: 2.3.0 + optionalDependencies: + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) transitivePeerDependencies: - '@vitejs/devtools' - esbuild diff --git a/scripts/analytics-reconcile/reconcile.ts b/scripts/analytics-reconcile/reconcile.ts index dd2f4160..2e0cee01 100644 --- a/scripts/analytics-reconcile/reconcile.ts +++ b/scripts/analytics-reconcile/reconcile.ts @@ -18,6 +18,53 @@ import type { Dashboard, DashboardWidget, Dataset, Report } from '@objectstack/s import type { FilterCondition } from '@objectstack/spec/data'; import type { DatasetSelection } from '@objectstack/spec/contracts'; +/** + * The LEGACY inline-query surface. + * + * `@objectstack/spec/ui` models the dataset-bound form — `DashboardWidget` has + * no `object` / `categoryField` / `aggregate` / `measures`, and `Report` has no + * `objectName` / `groupingsDown` / `groupingsAcross`, and types `columns` as + * `string[]`. Those keys are exactly the pre-ADR-0021 inline form this module + * exists to compare against, so they are declared here rather than reached for + * off an `any`. + * + * This file was outside `tsconfig.json`'s `include` until the verification- + * pipeline pass, so every one of these accesses had gone unchecked since the + * module was written. + */ + +/** One aggregate column of a legacy report (`columns` may also hold bare names). */ +export interface InlineReportColumn { + field: string; + aggregate?: string; +} + +/** One grouping level of a legacy report. */ +export interface InlineReportGrouping { + field: string; +} + +/** A dashboard widget that may still carry its pre-dataset inline query. */ +export type InlineQueryWidget = DashboardWidget & { + object?: string; + categoryField?: string; + categoryGranularity?: string; + valueField?: string; + aggregate?: string; + measures?: Array<{ aggregate?: string; valueField?: string }>; +}; + +/** A report that may still carry its pre-dataset inline query. */ +export type InlineQueryReport = Omit & { + objectName?: string; + filter?: FilterCondition; + runtimeFilter?: FilterCondition; + columns?: Array; + groupingsDown?: InlineReportGrouping[]; + groupingsAcross?: InlineReportGrouping[]; + blocks?: Array & { name?: string; dataset?: unknown }>; +}; + /** A groupBy item — a plain field, or a date field bucketed by a granularity. */ export type GroupByItem = string | { field: string; dateGranularity: string }; @@ -75,8 +122,8 @@ const EPSILON = 1e-9; * `object` query AND the new `dataset` binding with at least one measure name. */ export function isReconcilableWidget( - w: DashboardWidget, -): w is DashboardWidget & { object: string; dataset: string; values: string[] } { + w: InlineQueryWidget, +): w is InlineQueryWidget & { object: string; dataset: string; values: string[] } { return ( typeof w.object === 'string' && typeof w.dataset === 'string' && @@ -91,7 +138,7 @@ export function isReconcilableWidget( * `filter`, NOT from the dataset. That independence is the whole point: if the * dataset was authored to mean something different, the numbers diverge here. */ -export function deriveOldAggregate(w: DashboardWidget): OldAggregateSpec { +export function deriveOldAggregate(w: InlineQueryWidget): OldAggregateSpec { let groupBy: GroupByItem[] = w.categoryField ? [w.categoryGranularity ? { field: w.categoryField, dateGranularity: w.categoryGranularity } @@ -118,7 +165,7 @@ export function deriveOldAggregate(w: DashboardWidget): OldAggregateSpec { } /** Build the dataset selection from the widget's `dimensions` / `values` / `filter`. */ -export function deriveSelection(w: DashboardWidget): DatasetSelection { +export function deriveSelection(w: InlineQueryWidget): DatasetSelection { const dimensions = w.dimensions ?? []; const selection: DatasetSelection = { dimensions, @@ -188,7 +235,7 @@ function diff( /** Reconcile a single dual-form widget against its dataset. */ export async function reconcileWidget( - w: DashboardWidget, + w: InlineQueryWidget, dataset: Dataset | undefined, exec: ReconcileExecutors, ): Promise { @@ -217,7 +264,7 @@ export async function reconcileWidget( // Resolve date macros once, then feed the identical filter to both paths. const wResolved = exec.resolveFilter - ? ({ ...w, filter: exec.resolveFilter(w.filter) } as DashboardWidget) + ? ({ ...w, filter: exec.resolveFilter(w.filter) } as InlineQueryWidget) : w; const oldSpec = deriveOldAggregate(wResolved); const selection = deriveSelection(wResolved); @@ -263,8 +310,8 @@ export async function reconcileWidget( /** A report is reconcilable when it carries BOTH the inline query and the dataset binding. */ export function isReconcilableReport( - r: Report, -): r is Report & { objectName: string; dataset: string; values: string[] } { + r: InlineQueryReport, +): r is InlineQueryReport & { objectName: string; dataset: string; values: string[] } { return ( typeof r.objectName === 'string' && typeof r.dataset === 'string' && @@ -274,7 +321,7 @@ export function isReconcilableReport( } /** Lower a report's LEGACY inline fields (groupings + aggregate columns) to an aggregate spec. */ -export function deriveReportAggregate(r: Report): OldAggregateSpec { +export function deriveReportAggregate(r: InlineQueryReport): OldAggregateSpec { const groupBy = [ ...(r.groupingsDown ?? []).map((g) => g.field), ...(r.groupingsAcross ?? []).map((g) => g.field), @@ -288,7 +335,7 @@ export function deriveReportAggregate(r: Report): OldAggregateSpec { /** Reconcile a single dual-form report against its dataset. */ export async function reconcileReport( - r: Report, + r: InlineQueryReport, dataset: Dataset | undefined, exec: ReconcileExecutors, ): Promise { @@ -328,7 +375,7 @@ export async function reconcileReport( /** Reconcile every dual-form report. */ export async function reconcileReports( - reports: Report[], + reports: InlineQueryReport[], datasets: Map, exec: ReconcileExecutors, ): Promise { @@ -344,7 +391,7 @@ export async function reconcileReports( } // A block is shaped like a mini-report (objectName/columns/groupings/ // filter + dataset/rows/values/runtimeFilter) — reconcile it as one. - const asReport = { ...block, name: id } as unknown as Report; + const asReport = { ...block, name: id } as unknown as InlineQueryReport; results.push(await reconcileReport(asReport, datasets.get(block.dataset), exec)); } continue; diff --git a/scripts/analytics-reconcile/run.ts b/scripts/analytics-reconcile/run.ts index 34fa3ec1..8f94368c 100644 --- a/scripts/analytics-reconcile/run.ts +++ b/scripts/analytics-reconcile/run.ts @@ -7,14 +7,18 @@ // `aggregate()` vs the new dataset `queryDataset()` and asserts identical // numbers. Exits non-zero on any mismatch. Read-only. // -// pnpm tsx scripts/analytics-reconcile/run.ts +// pnpm reconcile:analytics +// +// (This used to document `pnpm tsx scripts/analytics-reconcile/run.ts`, but +// `tsx` was not a dependency of this repo and no script invoked it, so the +// documented command could not run.) import { ObjectKernel, DriverPlugin, AppPlugin } from '@objectstack/runtime'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { ObjectQLPlugin } from '@objectstack/objectql'; import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; import { DatasetSchema } from '@objectstack/spec/ui'; -import type { Dataset, Dashboard, Report } from '@objectstack/spec/ui'; +import type { Dataset, Dashboard } from '@objectstack/spec/ui'; import type { IAnalyticsService, DatasetSelection } from '@objectstack/spec/contracts'; import type { FilterCondition } from '@objectstack/spec/data'; @@ -23,6 +27,7 @@ import { reconcileReports, type ReconcileExecutors, type WidgetReconcileResult, + type InlineQueryReport, } from './reconcile.js'; import { resolveDateMacros } from './macros.js'; @@ -102,7 +107,11 @@ async function main(): Promise { for (const dashboard of Object.values(dashboardMod) as Dashboard[]) { mismatches += report(`hotcrm · ${dashboard.name}`, await reconcileDashboard(dashboard, datasets, exec)); } - const reports = Object.values(reportMod) as Report[]; + // `reportMod` exports the authored report metadata, which still carries the + // legacy inline query (`objectName`, `groupingsDown`, aggregate `columns`) + // alongside the dataset binding. `Report` from @objectstack/spec/ui models + // only the dataset-bound half, so widen to the reconciler's inline-aware view. + const reports = Object.values(reportMod) as unknown as InlineQueryReport[]; if (reports.length) mismatches += report('hotcrm · reports', await reconcileReports(reports, datasets, exec)); return mismatches; } diff --git a/scripts/check-source-hygiene.mjs b/scripts/check-source-hygiene.mjs new file mode 100644 index 00000000..9859f1a4 --- /dev/null +++ b/scripts/check-source-hygiene.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Source hygiene gate. + * + * These three checks used to live as inline `grep`/`find` one-liners in + * `.github/workflows/code-quality.yml`. All three scanned `packages/` — a + * directory this repository has never had — and all three carried + * `continue-on-error: true`, so they reported success without ever reading a + * single file of ours. + * + * They now scan the real source tree and FAIL the build, which is only + * defensible because the tree is already clean on all three counts. Running + * this locally is the same command CI runs: + * + * node scripts/check-source-hygiene.mjs + * + * Scope note: `src/` is declarative CRM metadata plus the hook/flow handler + * bodies that the runtime executes. Diagnostics there belong on `ctx.logger`, + * which is why `console.log` is banned. `scripts/` is deliberately NOT scanned + * for `console.log` — those files are CLIs whose entire job is stdout. + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); + +/** Directories that never contain first-party source. */ +const SKIP_DIRS = new Set(['node_modules', 'dist', '.next', '.source', '.objectstack', '.git']); + +/** Max byte size for a single first-party source file. */ +const MAX_FILE_BYTES = 100 * 1024; + +/** Recursively collect files under `dir` (repo-relative paths). */ +function walk(dir) { + const out = []; + let entries; + try { + entries = readdirSync(join(ROOT, dir), { withFileTypes: true }); + } catch { + return out; // directory absent — the caller decides whether that is fatal + } + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) continue; + const rel = join(dir, entry.name); + if (entry.isDirectory()) out.push(...walk(rel)); + else if (entry.isFile()) out.push(rel); + } + return out; +} + +const isTs = (f) => f.endsWith('.ts') && !f.endsWith('.d.ts'); + +/** + * Report every line in `files` matching `pattern`. + * @returns {{file: string, line: number, text: string}[]} + */ +function grep(files, pattern) { + const hits = []; + for (const file of files) { + const lines = readFileSync(join(ROOT, file), 'utf8').split('\n'); + lines.forEach((text, i) => { + if (pattern.test(text)) hits.push({ file, line: i + 1, text: text.trim() }); + }); + } + return hits; +} + +const failures = []; + +function check(name, hits, remedy) { + if (hits.length === 0) { + console.log(` ✓ ${name}`); + return; + } + console.log(` ✗ ${name} — ${hits.length} violation(s)`); + for (const h of hits.slice(0, 20)) { + console.log(` ${h.file}:${h.line} ${h.text.slice(0, 120)}`); + } + if (hits.length > 20) console.log(` … and ${hits.length - 20} more`); + console.log(` → ${remedy}`); + failures.push(name); +} + +// The directories these checks are guarding. A typo here (or a directory that +// gets renamed out from under us) must be loud, not silently vacuous — that is +// the exact failure mode this script exists to fix. +const SCANNED = ['src', 'test', 'e2e', 'scripts']; +const missing = SCANNED.filter((d) => { + try { + return !statSync(join(ROOT, d)).isDirectory(); + } catch { + return true; + } +}); +if (missing.length) { + console.error(`✗ source hygiene: scanned director(y|ies) missing: ${missing.join(', ')}`); + console.error(' Update SCANNED in scripts/check-source-hygiene.mjs — a check that'); + console.error(' scans nothing is worse than no check at all.'); + process.exit(1); +} + +const allFiles = SCANNED.flatMap(walk); +const allTs = allFiles.filter(isTs); +const srcTs = allTs.filter((f) => f.startsWith('src/')); + +console.log(`Source hygiene — ${allFiles.length} files under ${SCANNED.join(', ')}\n`); + +check( + 'no console.log in src/', + grep(srcTs, /\bconsole\.log\b/), + 'use ctx.logger inside hook/flow handlers; src/ is runtime code, not a CLI', +); + +check( + 'no TODO/FIXME markers', + grep(allTs, /\b(TODO|FIXME)\b/), + 'file an issue and link it, or finish the work — a marker in main is invisible', +); + +check( + `no source file over ${MAX_FILE_BYTES / 1024}KB`, + allFiles + .filter((f) => statSync(join(ROOT, f)).size > MAX_FILE_BYTES) + .map((f) => ({ + file: f, + line: 0, + text: `${(statSync(join(ROOT, f)).size / 1024).toFixed(0)}KB`, + })), + 'split the file — oversized modules defeat review', +); + +console.log(''); +if (failures.length) { + console.error(`✗ source hygiene failed: ${failures.join(', ')}`); + process.exit(1); +} +console.log('✓ source hygiene clean'); diff --git a/scripts/wow1-live-schema.sh b/scripts/wow1-live-schema.sh index 60d37750..4f10c02e 100755 --- a/scripts/wow1-live-schema.sh +++ b/scripts/wow1-live-schema.sh @@ -15,10 +15,18 @@ # 'health_score', order: 'desc' }], limit: 5)`. # t≈20s : Copilot answers, citing record IDs, surfacing health_score. # -# Requires hotcrm dev server running on PORT (default 4001) with a -# bearer token in HOTCRM_TOKEN. +# Requires hotcrm running on PORT (default 4001) with a bearer token in +# HOTCRM_TOKEN, AND a runtime that provides the `ai` capability. # -# Usage: +# ⚠ THIS DOES NOT RUN AGAINST A PLAIN `pnpm dev` / `pnpm start`. +# +# ObjectStack 11.3.0 (ADR-0025 S2) removed `@objectstack/service-ai` from the +# open edition, so `objectstack.config.ts` deliberately omits `ai` from +# `requires` — see the comment there. Nothing mounts `/api/v1/ai/*` locally and +# every call below returns 404. The preflight makes that failure immediate and +# self-explanatory instead of a bare `curl: (22) 404` twenty lines in. +# +# Usage (against a runtime that provides the `ai` tier, e.g. objectos-runtime): # PORT=4001 HOTCRM_TOKEN=sk-... ./scripts/wow1-live-schema.sh set -euo pipefail @@ -29,6 +37,28 @@ TOKEN="${HOTCRM_TOKEN:?Set HOTCRM_TOKEN to a valid bearer token}" AUTH="Authorization: Bearer ${TOKEN}" say() { printf '\n\033[1;34m▶ %s\033[0m\n' "$*"; } +die() { printf '\n\033[1;31m✗ %s\033[0m\n' "$*" >&2; exit 1; } + +# ── 0. Preflight ─────────────────────────────────────────────────── +curl -fsS -o /dev/null "${BASE}/api/v1/health" \ + || die "No hotcrm server answering on ${BASE}. Start one first (pnpm dev)." + +AI_STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "${BASE}/api/v1/ai/chat" \ + -H "${AUTH}" -H 'Content-Type: application/json' -d '{}') +if [ "${AI_STATUS}" = "404" ]; then + die "$(cat <<'MSG' +This runtime does not mount /api/v1/ai/* — the demo cannot run here. + +hotcrm drops `ai` from `requires` on purpose (objectstack.config.ts): under +ObjectStack 16 an unmet `requires` entry is fail-fast, and the open edition no +longer ships @objectstack/service-ai. The app is portable and runs anywhere; +the AI surface only exists on a runtime that provides the `ai` tier. + +To run this demo, point PORT/HOTCRM_TOKEN at such a runtime (cloud's +objectos-runtime), or declare @objectstack/service-ai in your own stack. +MSG +)" +fi # ── 1. Add a brand-new field to crm_account ──────────────────────── say "t=0 Adding 'health_score' field to crm_account..." diff --git a/test/actions-flows-integrity.test.ts b/test/actions-flows-integrity.test.ts index 8dc03bf8..3ca9e0d8 100644 --- a/test/actions-flows-integrity.test.ts +++ b/test/actions-flows-integrity.test.ts @@ -28,6 +28,19 @@ const objects: AnyRec[] = (stack as any).objects ?? []; const action = (name: string) => actions.find((a) => a.name === name); const flow = (name: string) => flows.find((f) => f.name === name); +/** + * A hook by name, or a thrown error naming the miss. + * + * The `const h = hooks.find(...)` / `expect(h).toBeTruthy()` / `h.events` shape + * these call sites used reads fine but does not narrow `h` for the compiler — + * something nothing noticed while `test/` sat outside tsconfig's `include`. + * Throwing keeps the diagnostic and gives the rest of the test a real value. + */ +const hookNamed = (name: string): AnyRec => { + const h = hooks.find((x) => x.name === name); + if (!h) throw new Error(`no ${name} hook registered`); + return h; +}; const nodeOf = (f: AnyRec | undefined, id: string) => (f?.nodes ?? []).find((n: AnyRec) => n.id === id); const fieldNames = (obj: string) => Object.keys(objects.find((o) => o.name === obj)?.fields ?? {}); @@ -147,16 +160,14 @@ describe('notify recipients resolve to real audiences', () => { describe('line-item rollups keep parent totals in sync', () => { it('opportunity amount rollup fires on line-item insert/update/delete', () => { - const h = hooks.find((x) => x.name === 'opportunity_amount_rollup'); - expect(h, 'no opportunity_amount_rollup hook').toBeTruthy(); + const h = hookNamed('opportunity_amount_rollup'); for (const ev of ['afterInsert', 'afterUpdate', 'afterDelete']) { expect(h.events, `rollup must fire on ${ev}`).toContain(ev); } }); it('quote total rollup fires on line-item insert/update/delete', () => { - const h = hooks.find((x) => x.name === 'quote_total_rollup'); - expect(h, 'no quote_total_rollup hook').toBeTruthy(); + const h = hookNamed('quote_total_rollup'); for (const ev of ['afterInsert', 'afterUpdate', 'afterDelete']) { expect(h.events, `rollup must fire on ${ev}`).toContain(ev); } @@ -164,8 +175,7 @@ describe('line-item rollups keep parent totals in sync', () => { it('line-item price-fill hooks default price from the product on write', () => { for (const name of ['opportunity_line_item_price_fill', 'quote_line_item_price_fill']) { - const h = hooks.find((x) => x.name === name); - expect(h, `no ${name} hook`).toBeTruthy(); + const h = hookNamed(name); expect(h.events).toContain('beforeInsert'); } }); @@ -185,8 +195,7 @@ describe('lead conversion dedupes the contact too', () => { describe('lead auto-assignment', () => { it('a beforeInsert hook assigns ownerless leads', () => { - const h = hooks.find((x) => x.name === 'lead_auto_assign'); - expect(h, 'no lead_auto_assign hook').toBeTruthy(); + const h = hookNamed('lead_auto_assign'); expect(h.events).toContain('beforeInsert'); }); }); diff --git a/test/docs-drift.test.ts b/test/docs-drift.test.ts index 66f36aa6..55fc52a4 100644 --- a/test/docs-drift.test.ts +++ b/test/docs-drift.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { REPO_ROOT } from './helpers/repo-root'; /** * Docs ↔ source drift guard (the AI-era safety net). @@ -22,8 +23,13 @@ import { join } from 'node:path'; * markdown) so it stays readable and has no runtime/server dependency. */ -const FLOWS = (f: string) => readFileSync(join('src/flows', f), 'utf8'); -const DOC = (f: string) => readFileSync(join('src/docs', f), 'utf8'); +// Resolved from this file's own location, not `process.cwd()`. The previous +// `join('src/flows', f)` only worked when vitest happened to be launched from +// the repo root — from a subdirectory, or an editor runner with a different +// working directory, this drift guard died with ENOENT instead of checking +// anything. +const FLOWS = (f: string) => readFileSync(join(REPO_ROOT, 'src/flows', f), 'utf8'); +const DOC = (f: string) => readFileSync(join(REPO_ROOT, 'src/docs', f), 'utf8'); /** cron → the human label the docs use. Unknown cron ⇒ deliberate failure. */ const CRON_LABEL: Record = { @@ -174,9 +180,12 @@ const TREE_DOCS = [ describe('maintainer docs do not point at directories that no longer exist', () => { for (const docFile of TREE_DOCS) { it(`${docFile}: every src// it names exists`, () => { - const text = readFileSync(join(docFile), 'utf8'); + // Anchored on REPO_ROOT for the same reason as FLOWS/DOC above: a + // cwd-relative read turns this guard into an ENOENT the moment vitest is + // launched from anywhere but the repo root. + const text = readFileSync(join(REPO_ROOT, docFile), 'utf8'); const named = new Set([...text.matchAll(/\bsrc\/([a-z][a-z0-9_]*)\//g)].map((m) => m[1])); - const missing = [...named].filter((dir) => !existsSync(join('src', dir))); + const missing = [...named].filter((dir) => !existsSync(join(REPO_ROOT, 'src', dir))); expect( missing, `${docFile} advertises src/ directories that do not exist: ${missing.join(', ')}. ` + diff --git a/test/flow-case-actions.test.ts b/test/flow-case-actions.test.ts new file mode 100644 index 00000000..f32f1645 --- /dev/null +++ b/test/flow-case-actions.test.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { CloseCaseFlow, EscalateCaseFlow } from '../src/flows/case-actions.flow'; +import { makeFlowHarness, type Rec } from './helpers/flow-harness'; + +/** + * Runtime tests for the two case screen-flow actions. + * + * These are the console's record-action buttons. Two things about them are + * silently breakable and neither is visible to metadata validation: + * + * 1. The input variable MUST be named `recordId` — the console's flow-action + * contract seeds only that name, so a renamed variable arrives `undefined` + * and the update targets `{ id: undefined }`, matching nothing. + * 2. `escalate_case` must write `escalation_reason` alongside `is_escalated`, + * or the object's `escalation_reason_required` validation (severity: error) + * rejects the whole write and the button appears to do nothing. + */ + +const openCase = (over: Rec = {}): Rec => ({ + id: 'c1', case_number: 'CASE-1', status: 'new', priority: 'medium', + is_escalated: false, is_closed: false, owner: 'agent1', ...over, +}); + +/** Start a screen flow and resume it with the values the screen collects. */ +async function runScreen( + flowName: string, + flow: Rec, + seed: Rec[], + screen: Rec, +): Promise> { + const h = makeFlowHarness({ [flowName]: flow as never }, { crm_case: seed }); + const runId = await h.run(flowName, { recordId: 'c1' }); + expect(runId, `${flowName} did not start`).toBeTruthy(); + await h.resume(runId!, screen); + return h; +} + +describe('escalate_case — screen action', () => { + it('seeds its input from the console’s `recordId` contract', () => { + const names = (EscalateCaseFlow.variables ?? []).map((v) => v.name); + // A custom name (e.g. `caseId`) arrives undefined and the update matches + // nothing — the button silently no-ops. + expect(names, 'the console only seeds `recordId`').toContain('recordId'); + }); + + it('flags, re-prioritises and stamps the case from the collected reason', async () => { + const h = await runScreen('escalate_case', EscalateCaseFlow as unknown as Rec, [openCase()], { + recordId: 'c1', reason: 'Customer threatening churn', + }); + + const updated = h.store.crm_case[0]; + expect(updated.is_escalated).toBe(true); + expect(updated.status).toBe('escalated'); + expect(updated.priority).toBe('critical'); + expect(updated.escalation_reason).toBe('Customer threatening churn'); + expect(updated.escalated_date, 'escalated_date suppresses a double-fire').toBeTruthy(); + }); + + it('always supplies the reason its validation rule requires', async () => { + // `escalation_reason_required` rejects any write that flips is_escalated + // without a reason, which silently aborted the action. + const h = await runScreen('escalate_case', EscalateCaseFlow as unknown as Rec, [openCase()], { + recordId: 'c1', reason: 'Breach', + }); + const updated = h.store.crm_case[0]; + expect(updated.is_escalated && !!updated.escalation_reason).toBe(true); + }); + + it('stamps escalated_date so the automatic escalation flow does not re-fire', async () => { + // `case_escalation`'s start condition is `escalated_date == null`; without + // this stamp the record-change flow escalates the case a second time. + const h = await runScreen('escalate_case', EscalateCaseFlow as unknown as Rec, [openCase()], { + recordId: 'c1', reason: 'Breach', + }); + expect(h.store.crm_case[0].escalated_date).toBeTruthy(); + }); +}); + +describe('close_case — screen action', () => { + it('seeds its input from the console’s `recordId` contract', () => { + const names = (CloseCaseFlow.variables ?? []).map((v) => v.name); + expect(names).toContain('recordId'); + }); + + it('closes the case and records the resolution', async () => { + const h = await runScreen('close_case', CloseCaseFlow as unknown as Rec, [openCase()], { + recordId: 'c1', resolution: 'Replaced the faulty unit', + }); + + const updated = h.store.crm_case[0]; + expect(updated.status).toBe('closed'); + expect(updated.is_closed).toBe(true); + expect(updated.resolution).toBe('Replaced the faulty unit'); + }); + + it('leaves other cases untouched', async () => { + const h = await runScreen( + 'close_case', + CloseCaseFlow as unknown as Rec, + [openCase(), openCase({ id: 'c2', case_number: 'CASE-2' })], + { recordId: 'c1', resolution: 'Done' }, + ); + const other = h.store.crm_case.find((c) => c.id === 'c2')!; + expect(other.status).toBe('new'); + expect(other.is_closed).toBe(false); + }); +}); diff --git a/test/flow-record-change.test.ts b/test/flow-record-change.test.ts new file mode 100644 index 00000000..97837c13 --- /dev/null +++ b/test/flow-record-change.test.ts @@ -0,0 +1,349 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { CaseEscalationFlow, CaseEscalationOnCreateFlow } from '../src/flows/case-escalation.flow'; +import { ContactWelcomeFlow } from '../src/flows/contact-welcome.flow'; +import { LeadAssignmentFlow } from '../src/flows/lead-assignment.flow'; +import { + OpportunityApprovalFlow, OpportunityApprovalOnCreateFlow, +} from '../src/flows/opportunity-approval.flow'; +import { OpportunityWonAlertFlow } from '../src/flows/opportunity-won-alert.flow'; +import { TaskUrgentAlertFlow } from '../src/flows/task-urgent-alert.flow'; +import { makeFlowHarness, type Rec } from './helpers/flow-harness'; + +/** + * Runtime tests for the RECORD-CHANGE flows. + * + * The interesting logic in these lives in the start `condition` — a bare CEL + * expression the engine wraps and evaluates against `record` / `previous`. + * Nearly every comment in these flow files documents a re-fire loop or a + * phantom-recipient bug that shipped, and every one of those was invisible to + * `os validate` and to the metadata-contract tests, which can only see that a + * condition string exists. + * + * These execute the real engine and assert on the notification payloads and + * record writes that come out the other side. + */ + +/** Evaluate a flow's start condition exactly as the engine does. */ +function startConditionHolds(flow: Rec, vars: Record): boolean { + const h = makeFlowHarness({}, {}); + const engine = h.engine as unknown as { + evaluateCondition(c: unknown, v: Map): boolean; + }; + const condition = (flow.nodes as Rec[]).find((n) => n.id === 'start')?.config?.condition; + // The engine wraps a string start condition into a CEL envelope before + // evaluating it (unlike loop-nested edge conditions — see + // flow-scheduled.test.ts). Mirror that here so this is the real code path. + const expr = typeof condition === 'string' ? { dialect: 'cel', source: condition } : condition; + return engine.evaluateCondition(expr, new Map(Object.entries(vars))); +} + +describe('opportunity_won_alert — start condition', () => { + const opp = (over: Rec = {}): Rec => ({ + id: 'o1', name: 'Big Deal', stage: 'closed_won', amount: 250_000, owner: 'rep1', ...over, + }); + + it('fires on the TRANSITION into closed_won above $100K', () => { + expect(startConditionHolds(OpportunityWonAlertFlow, { + record: opp(), previous: { stage: 'negotiation' }, + })).toBe(true); + }); + + it('does NOT re-fire on a later edit of an already-won deal', () => { + // Without the `previous.stage` guard every subsequent edit of a won deal + // (owner claims, approval stamps, description tweaks) re-sent the blast. + expect(startConditionHolds(OpportunityWonAlertFlow, { + record: opp(), previous: { stage: 'closed_won' }, + })).toBe(false); + }); + + it('ignores deals at or below the $100K threshold', () => { + for (const amount of [100_000, 50_000]) { + expect(startConditionHolds(OpportunityWonAlertFlow, { + record: opp({ amount }), previous: { stage: 'negotiation' }, + }), `amount ${amount} should not alert`).toBe(false); + } + }); + + it('ignores deals that did not close won', () => { + expect(startConditionHolds(OpportunityWonAlertFlow, { + record: opp({ stage: 'closed_lost' }), previous: { stage: 'negotiation' }, + })).toBe(false); + }); + + it('notifies the owner without dot-walking the manager lookup', async () => { + const h = makeFlowHarness({ opportunity_won_alert: OpportunityWonAlertFlow }, {}); + await h.trigger('opportunity_won_alert', opp(), { stage: 'negotiation' }); + + expect(h.notifications).toHaveLength(1); + const [alert] = h.notifications; + expect(alert.to).toContain('rep1'); + // `{record.owner.manager}` interpolates to the literal "undefined" and the + // message goes to a phantom user. + expect(JSON.stringify(alert), 'a template dot-walked a lookup').not.toContain('undefined'); + expect(String(alert.title)).toContain('Big Deal'); + }); +}); + +describe('case_escalation — start condition', () => { + const critical = (over: Rec = {}): Rec => ({ + id: 'c1', case_number: 'CASE-1', priority: 'critical', status: 'new', + escalated_date: null, owner: 'agent1', ...over, + }); + + it('fires for a fresh critical case', () => { + expect(startConditionHolds(CaseEscalationFlow, { record: critical(), previous: {} })).toBe(true); + }); + + it('does not re-fire once escalated_date is stamped', () => { + // The flow's own escalation write re-triggers record-after-update. The + // guard must NOT be the `is_escalated` boolean: on SQLite/libsql a boolean + // persists as integer 1, so `is_escalated != true` is `1 != true` = true + // and the flow loops forever (it wedged a first-boot seed). + expect(startConditionHolds(CaseEscalationFlow, { + record: critical({ escalated_date: '2026-01-01T00:00:00.000Z', status: 'escalated' }), + previous: {}, + })).toBe(false); + }); + + it('does not re-escalate a case that is being resolved or closed', () => { + // Observed live: close_case wrote status "closed" and this flow immediately + // rewrote it back to "escalated". + for (const status of ['resolved', 'closed', 'escalated']) { + expect(startConditionHolds(CaseEscalationFlow, { + record: critical({ status }), previous: {}, + }), `status ${status} must not escalate`).toBe(false); + } + }); + + it('ignores non-critical cases', () => { + for (const priority of ['low', 'medium', 'high']) { + expect(startConditionHolds(CaseEscalationFlow, { + record: critical({ priority }), previous: {}, + })).toBe(false); + } + }); + + it('flags the case with the reason its validation rule requires', async () => { + const h = makeFlowHarness({ case_escalation: CaseEscalationFlow }, { + crm_case: [critical()], + }); + await h.trigger('case_escalation', critical(), {}); + + const updated = h.store.crm_case[0]; + expect(updated.is_escalated).toBe(true); + expect(updated.status).toBe('escalated'); + // `escalation_reason` must accompany `is_escalated` — the object's + // `escalation_reason_required` validation rejects the write otherwise, + // which silently aborted this flow until it was supplied. + expect(updated.escalation_reason, 'missing reason ⇒ the write is rejected').toBeTruthy(); + expect(updated.escalated_date).toBeTruthy(); + }); + + it('creates no task of its own — the status hook owns escalation tasks', async () => { + // A task node here produced duplicate, disagreeing tasks (case owner/high + // vs account owner/urgent) per escalation. + const h = makeFlowHarness({ case_escalation: CaseEscalationFlow }, { + crm_case: [critical()], crm_task: [], + }); + await h.trigger('case_escalation', critical(), {}); + expect(h.store.crm_task).toHaveLength(0); + }); +}); + +describe('contact_welcome — start condition', () => { + const contact = (over: Rec = {}): Rec => ({ + id: 'c1', first_name: 'Ada', last_name: 'Lovelace', owner: 'rep1', + email_opt_out: false, ...over, + }); + + it('fires for an owned contact who has not opted out', () => { + expect(startConditionHolds(ContactWelcomeFlow, { record: contact() })).toBe(true); + }); + + it('respects email_opt_out', () => { + expect(startConditionHolds(ContactWelcomeFlow, { + record: contact({ email_opt_out: true }), + })).toBe(false); + }); + + it('skips ownerless contacts (nobody to prompt)', () => { + expect(startConditionHolds(ContactWelcomeFlow, { + record: contact({ owner: null }), + })).toBe(false); + }); + + it('addresses the prompt to the owner with a resolved name', async () => { + const h = makeFlowHarness({ contact_welcome: ContactWelcomeFlow }, {}); + await h.trigger('contact_welcome', contact()); + + expect(h.notifications).toHaveLength(1); + const [alert] = h.notifications; + expect(alert.to).toContain('rep1'); + expect(String(alert.title)).toContain('Ada'); + expect(String(alert.title)).toContain('Lovelace'); + }); +}); + +describe('task_urgent_alert — start condition', () => { + const task = (over: Rec = {}): Rec => ({ + id: 't1', subject: 'Fix outage', priority: 'urgent', status: 'not_started', + owner: 'rep1', ...over, + }); + + it('fires for a new urgent, incomplete task', () => { + expect(startConditionHolds(TaskUrgentAlertFlow, { record: task() })).toBe(true); + }); + + it('gates on the status enum, not the is_completed boolean', () => { + // On SQLite/libsql booleans persist as integer 1, so `is_completed != true` + // is `1 != true` = always true and the guard never trips. + expect(startConditionHolds(TaskUrgentAlertFlow, { + record: task({ status: 'completed', is_completed: 1 }), + })).toBe(false); + }); + + it('ignores non-urgent tasks', () => { + for (const priority of ['low', 'normal', 'high']) { + expect(startConditionHolds(TaskUrgentAlertFlow, { record: task({ priority }) })).toBe(false); + } + }); + + it('alerts the owner at warning severity', async () => { + const h = makeFlowHarness({ task_urgent_alert: TaskUrgentAlertFlow }, {}); + await h.trigger('task_urgent_alert', task()); + expect(h.notifications).toHaveLength(1); + expect(h.notifications[0].to).toContain('rep1'); + expect(h.notifications[0].severity).toBe('warning'); + expect(String(h.notifications[0].title)).toContain('Fix outage'); + }); +}); + +describe('lead_assignment — hot-lead SLA routing', () => { + const lead = (over: Rec = {}): Rec => ({ + id: 'l1', company: 'Acme', rating: 5, owner: 'rep1', ...over, + }); + + it('routes a hot lead (rating ≥ 4) down the accelerated SLA branch', async () => { + const h = makeFlowHarness({ lead_assignment: LeadAssignmentFlow }, { crm_task: [] }); + await h.trigger('lead_assignment', lead({ rating: 5 })); + expect(h.notifications.length + h.store.crm_task.length, 'hot lead produced no follow-up').toBeGreaterThan(0); + }); + + it('routes a cold lead down the standard branch', async () => { + const h = makeFlowHarness({ lead_assignment: LeadAssignmentFlow }, { crm_task: [] }); + await h.trigger('lead_assignment', lead({ rating: 1 })); + expect(h.notifications.length + h.store.crm_task.length, 'cold lead produced no follow-up').toBeGreaterThan(0); + }); + + it('sends every SLA task to the lead owner, never a dot-walked manager', async () => { + for (const rating of [5, 1]) { + const h = makeFlowHarness({ lead_assignment: LeadAssignmentFlow }, { crm_task: [] }); + await h.trigger('lead_assignment', lead({ rating })); + for (const n of h.notifications) { + expect(JSON.stringify(n), `rating ${rating} notification dot-walked a lookup`) + .not.toContain('undefined'); + } + for (const t of h.store.crm_task) { + expect(String(t.owner), `rating ${rating} task has a phantom owner`).not.toBe('undefined'); + } + } + }); +}); + +describe('opportunity_approval — start condition', () => { + const startCondition = (OpportunityApprovalFlow.nodes as Rec[]) + .find((n) => n.id === 'start')?.config?.condition; + + it('declares a start condition that gates on the approval-worthy transition', () => { + expect(typeof startCondition, 'opportunity_approval lost its start condition').toBe('string'); + }); + + it('does not re-enter for an opportunity already pending approval', () => { + // The flow writes `approval_status`, which re-triggers record-after-update; + // without a guard it re-enters for the same record (the engine logs + // "flow re-entered for the same record while still running"). + const pending = { + record: { id: 'o1', amount: 750_000, approval_status: 'pending', stage: 'negotiation' }, + previous: { id: 'o1', amount: 750_000, approval_status: 'pending', stage: 'negotiation' }, + }; + expect(startConditionHolds(OpportunityApprovalFlow, pending)).toBe(false); + }); +}); + +/** + * Insert-time twins. + * + * A record-change flow binds exactly ONE hook event, so each of these pairs + * exists to cover the other half: the afterUpdate flow never sees a record that + * is BORN in the triggering state (a phone-in P1 case, an API-inserted large + * deal) — which for `case_escalation` is the common path. + * + * The twins are derived by spreading the parent and rebinding only the start + * node's `triggerType`. The failure mode is drift: someone edits the parent's + * nodes or its start condition and the twin quietly keeps the old behaviour, or + * the twin's trigger gets rebound back to update and the insert path silently + * stops working. Both are asserted structurally here, and the shared condition + * is exercised through the same engine path as its parent. + */ +describe('insert-time twin flows', () => { + const TWINS = [ + { twin: CaseEscalationOnCreateFlow, parent: CaseEscalationFlow, name: 'case_escalation_on_create' }, + { twin: OpportunityApprovalOnCreateFlow, parent: OpportunityApprovalFlow, name: 'opportunity_approval_on_create' }, + ] as const; + + it.each(TWINS)('$name is registered under its own name', ({ twin, parent, name }) => { + expect(twin.name).toBe(name); + expect(twin.name, 'twin collides with its parent').not.toBe(parent.name); + }); + + it.each(TWINS)('$name binds record-after-create, unlike its parent', ({ twin, parent }) => { + const startOf = (f: Rec) => (f.nodes as Rec[]).find((n) => n.id === 'start')!; + expect(startOf(twin as unknown as Rec).config.triggerType).toBe('record-after-create'); + expect(startOf(parent as unknown as Rec).config.triggerType).toBe('record-after-update'); + }); + + it.each(TWINS)('$name keeps its parent’s graph and start condition', ({ twin, parent }) => { + const t = twin as unknown as Rec; + const p = parent as unknown as Rec; + // Same graph: only the start node's triggerType may differ. + expect((t.nodes as Rec[]).map((n) => n.id)).toEqual((p.nodes as Rec[]).map((n) => n.id)); + expect(t.edges).toEqual(p.edges); + + const cond = (f: Rec) => (f.nodes as Rec[]).find((n) => n.id === 'start')!.config.condition; + expect(cond(t), 'twin drifted from its parent’s start condition').toEqual(cond(p)); + + // Every non-start node must be byte-identical to the parent's. + for (const node of t.nodes as Rec[]) { + if (node.id === 'start') continue; + const twinNode = (p.nodes as Rec[]).find((n) => n.id === node.id); + expect(node, `node ${node.id} drifted from the parent flow`).toEqual(twinNode); + } + }); + + it('case_escalation_on_create escalates a case born critical', async () => { + const born = { + id: 'c9', case_number: 'CASE-9', priority: 'critical', status: 'new', + escalated_date: null, owner: 'agent1', + }; + const h = makeFlowHarness({ case_escalation_on_create: CaseEscalationOnCreateFlow }, { + crm_case: [{ ...born }], + }); + await h.trigger('case_escalation_on_create', born); + + const updated = h.store.crm_case[0]; + expect(updated.is_escalated, 'a case born critical was never escalated').toBe(true); + expect(updated.status).toBe('escalated'); + expect(updated.escalation_reason).toBeTruthy(); + }); + + it('opportunity_approval_on_create declares the same gate as its parent', () => { + const pending = { + record: { id: 'o1', amount: 750_000, approval_status: 'pending', stage: 'negotiation' }, + previous: { id: 'o1', amount: 750_000, approval_status: 'pending', stage: 'negotiation' }, + }; + expect(startConditionHolds(OpportunityApprovalOnCreateFlow as unknown as Rec, pending)) + .toBe(startConditionHolds(OpportunityApprovalFlow as unknown as Rec, pending)); + }); +}); diff --git a/test/flow-scheduled.test.ts b/test/flow-scheduled.test.ts new file mode 100644 index 00000000..84f065fc --- /dev/null +++ b/test/flow-scheduled.test.ts @@ -0,0 +1,449 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { CampaignCompletionFlow } from '../src/flows/campaign-completion.flow'; +import { CaseSlaMonitorFlow } from '../src/flows/case-sla-monitor.flow'; +import { ContractExpirationFlow } from '../src/flows/contract-expiration.flow'; +import { ContractRenewalFlow } from '../src/flows/contract-renewal.flow'; +import { OpportunityStagnationFlow } from '../src/flows/opportunity-stagnation.flow'; +import { QuoteExpirationFlow } from '../src/flows/quote-expiration.flow'; +import { TaskDueReminderFlow } from '../src/flows/task-due-reminder.flow'; +import { makeFlowHarness, type Rec } from './helpers/flow-harness'; + +/** + * Runtime tests for the SCHEDULED sweeps. + * + * All six scheduled flows were previously untested at runtime, and they are the + * hardest flows to get right for exactly the reason that makes them hard to + * test: they select their own work. A sweep whose filter matches nothing is + * indistinguishable from a sweep with nothing to do — it runs, logs nothing, + * and stays green forever. Every case below therefore seeds BOTH rows that must + * be picked up and rows that must be left alone, so a filter that silently + * matches everything (or nothing) fails. + * + * These run the real `AutomationEngine` over the in-memory data engine in + * `test/helpers/flow-harness.ts`, which — unlike the equality-only engines the + * older flow tests carried — implements `$lt` / `$lte` / `$nin` / `$in`. + */ + +const iso = (daysFromNow: number): string => { + const d = new Date(); + d.setDate(d.getDate() + daysFromNow); + return d.toISOString(); +}; +const day = (daysFromNow: number): string => iso(daysFromNow).slice(0, 10); + +describe('case_sla_monitor — hourly breach sweep', () => { + const seedCases = (): Rec[] => [ + // Breached and still open — MUST be flagged. + { + id: 'c_breached', case_number: 'CASE-1', status: 'working', priority: 'critical', + is_sla_violated: false, sla_due_date: iso(-2), owner: 'rep1', + }, + // Due in the future — must be left alone. + { + id: 'c_future', case_number: 'CASE-2', status: 'working', priority: 'high', + is_sla_violated: false, sla_due_date: iso(+2), owner: 'rep1', + }, + // Past due but already resolved: work is finished, so this is NOT a breach. + { + id: 'c_resolved', case_number: 'CASE-3', status: 'resolved', priority: 'high', + is_sla_violated: false, sla_due_date: iso(-5), owner: 'rep1', + }, + // Past due but closed — likewise excluded. + { + id: 'c_closed', case_number: 'CASE-4', status: 'closed', priority: 'low', + is_sla_violated: false, sla_due_date: iso(-9), owner: 'rep1', + }, + // Already flagged — must not be re-processed (or re-notified). + { + id: 'c_already', case_number: 'CASE-5', status: 'escalated', priority: 'critical', + is_sla_violated: true, sla_due_date: iso(-3), owner: 'rep1', + }, + ]; + + const runSweep = async () => { + const h = makeFlowHarness({ case_sla_monitor: CaseSlaMonitorFlow }, { crm_case: seedCases() }); + await h.run('case_sla_monitor', {}, { event: 'schedule' }); + return h; + }; + + it('flags and escalates exactly the open, past-due, not-yet-flagged cases', async () => { + const h = await runSweep(); + const byId = Object.fromEntries(h.store.crm_case.map((c) => [c.id, c])); + + const breached = byId.c_breached; + expect(breached.is_sla_violated).toBe(true); + expect(breached.is_escalated).toBe(true); + expect(breached.status).toBe('escalated'); + // `escalation_reason` must accompany `is_escalated` or the object's + // `escalation_reason_required` validation (severity: error) rejects the + // whole write and the sweep becomes a silent no-op. + expect(breached.escalation_reason, 'missing escalation_reason ⇒ write rejected').toBeTruthy(); + expect(breached.escalated_date).toBeTruthy(); + }); + + it('leaves future-due, resolved and closed cases untouched', async () => { + const h = await runSweep(); + const byId = Object.fromEntries(h.store.crm_case.map((c) => [c.id, c])); + + for (const id of ['c_future', 'c_resolved', 'c_closed']) { + expect(byId[id].is_sla_violated, `${id} was wrongly flagged`).toBe(false); + expect(byId[id].is_escalated, `${id} was wrongly escalated`).toBeFalsy(); + } + // Specifically: a resolved case has met its SLA. The filter used to read + // `is_closed: false`, which only flips on `closed`, so resolved cases were + // dragged back to `escalated`. + expect(byId.c_resolved.status).toBe('resolved'); + }); + + it('does not re-process a case already marked as violated', async () => { + const h = await runSweep(); + const already = h.store.crm_case.find((c) => c.id === 'c_already')!; + expect(already.escalation_reason, 'already-flagged case was re-written').toBeUndefined(); + }); + + it('alerts the case owner, and only for the newly-breached case', async () => { + const h = await runSweep(); + expect(h.notifications.length, 'expected exactly one alert').toBe(1); + + const [alert] = h.notifications; + const recipients = JSON.stringify(alert.to); + expect(recipients).toContain('rep1'); + // `{currentCase.owner.manager}` dot-walks a lookup, which flow templates + // interpolate as the literal string "undefined" — a silently undeliverable + // notification. Guard against that shape reappearing anywhere in the payload. + expect(JSON.stringify(alert), 'a template dot-walked a lookup').not.toContain('undefined'); + expect(alert.severity).toBe('critical'); + expect(String(alert.title)).toContain('CASE-1'); + }); + + it('is a clean no-op when nothing has breached', async () => { + const h = makeFlowHarness( + { case_sla_monitor: CaseSlaMonitorFlow }, + { + crm_case: [{ + id: 'c1', case_number: 'CASE-9', status: 'working', + is_sla_violated: false, sla_due_date: iso(+5), owner: 'rep1', + }], + }, + ); + await h.run('case_sla_monitor', {}, { event: 'schedule' }); + expect(h.store.crm_case[0].is_sla_violated).toBe(false); + expect(h.notifications).toHaveLength(0); + }); +}); + +describe('quote_expiration — daily auto-expiry', () => { + const run = async () => { + const h = makeFlowHarness({ quote_expiration: QuoteExpirationFlow }, { + crm_quote: [ + { id: 'q_past', status: 'presented', expiration_date: day(-1) }, // expire + { id: 'q_draft_past', status: 'draft', expiration_date: day(-30) }, // expire + { id: 'q_future', status: 'presented', expiration_date: day(+7) }, // keep + { id: 'q_accepted', status: 'accepted', expiration_date: day(-9) }, // settled + { id: 'q_rejected', status: 'rejected', expiration_date: day(-9) }, // settled + { id: 'q_expired', status: 'expired', expiration_date: day(-9) }, // already + ], + }); + await h.run('quote_expiration', {}, { event: 'schedule' }); + return Object.fromEntries(h.store.crm_quote.map((q) => [q.id, q])); + }; + + it('expires open quotes past their expiration_date', async () => { + const byId = await run(); + expect(byId.q_past.status).toBe('expired'); + expect(byId.q_draft_past.status).toBe('expired'); + }); + + it('leaves future-dated and already-settled quotes alone', async () => { + const byId = await run(); + expect(byId.q_future.status).toBe('presented'); + expect(byId.q_accepted.status).toBe('accepted'); + expect(byId.q_rejected.status).toBe('rejected'); + expect(byId.q_expired.status).toBe('expired'); + }); +}); + +describe('contract_expiration — daily auto-expiry', () => { + const run = async () => { + const h = makeFlowHarness({ contract_expiration: ContractExpirationFlow }, { + crm_contract: [ + { id: 'k_past', contract_number: 'CTR-1', status: 'activated', end_date: day(-1), owner: 'rep1' }, + { id: 'k_future', contract_number: 'CTR-2', status: 'activated', end_date: day(+30), owner: 'rep1' }, + { id: 'k_draft', contract_number: 'CTR-3', status: 'draft', end_date: day(-30), owner: 'rep1' }, + { id: 'k_expired', contract_number: 'CTR-4', status: 'expired', end_date: day(-60), owner: 'rep1' }, + ], + }); + await h.run('contract_expiration', {}, { event: 'schedule' }); + return h; + }; + + it('expires only activated contracts past their end_date', async () => { + const h = await run(); + const byId = Object.fromEntries(h.store.crm_contract.map((k) => [k.id, k])); + expect(byId.k_past.status).toBe('expired'); + expect(byId.k_future.status).toBe('activated'); + expect(byId.k_draft.status, 'a draft contract must not be auto-expired').toBe('draft'); + }); + + it('notifies the owner once, with a resolved contract number', async () => { + const h = await run(); + expect(h.notifications).toHaveLength(1); + const [alert] = h.notifications; + expect(alert.to).toContain('rep1'); + expect(String(alert.title)).toContain('CTR-1'); + expect(JSON.stringify(alert), 'a template failed to interpolate').not.toContain('undefined'); + }); +}); + +describe('campaign_completion — daily auto-completion', () => { + it('completes ended in_progress campaigns and leaves the rest', async () => { + const h = makeFlowHarness({ campaign_completion: CampaignCompletionFlow }, { + crm_campaign: [ + { id: 'cmp_ended', status: 'in_progress', end_date: day(-1) }, + { id: 'cmp_running', status: 'in_progress', end_date: day(+10) }, + { id: 'cmp_planning', status: 'planning', end_date: day(-10) }, + { id: 'cmp_done', status: 'completed', end_date: day(-10) }, + ], + }); + await h.run('campaign_completion', {}, { event: 'schedule' }); + + const byId = Object.fromEntries(h.store.crm_campaign.map((c) => [c.id, c])); + expect(byId.cmp_ended.status).toBe('completed'); + expect(byId.cmp_running.status).toBe('in_progress'); + // A campaign still in planning never ran, so "ended" does not apply. + expect(byId.cmp_planning.status).toBe('planning'); + expect(byId.cmp_done.status).toBe('completed'); + }); +}); + +describe('task_due_reminder — hourly reminder sweep', () => { + const run = async () => { + const h = makeFlowHarness({ task_due_reminder: TaskDueReminderFlow }, { + crm_task: [ + { id: 't_due', subject: 'Call Acme', owner: 'rep1', is_completed: false, reminder_date: iso(-1) }, + { id: 't_future', subject: 'Later', owner: 'rep1', is_completed: false, reminder_date: iso(+3) }, + { id: 't_done', subject: 'Done', owner: 'rep1', is_completed: true, reminder_date: iso(-5) }, + { id: 't_none', subject: 'No reminder', owner: 'rep1', is_completed: false, reminder_date: null }, + ], + }); + await h.run('task_due_reminder', {}, { event: 'schedule' }); + return h; + }; + + it('notifies the owner of a task whose reminder time has arrived', async () => { + const h = await run(); + expect(h.notifications).toHaveLength(1); + const [alert] = h.notifications; + expect(alert.to).toContain('rep1'); + expect(String(alert.title)).toContain('Call Acme'); + expect(alert.severity).toBe('warning'); + }); + + it('clears reminder_date so the same task is never alerted twice', async () => { + const h = await run(); + const byId = Object.fromEntries(h.store.crm_task.map((t) => [t.id, t])); + expect(byId.t_due.reminder_sent).toBe(true); + // Clearing the date is what de-dups: the row stops matching `$lte` next tick. + expect(byId.t_due.reminder_date).toBeNull(); + }); + + it('is idempotent across consecutive ticks', async () => { + const h = makeFlowHarness({ task_due_reminder: TaskDueReminderFlow }, { + crm_task: [{ id: 't_due', subject: 'Call Acme', owner: 'rep1', is_completed: false, reminder_date: iso(-1) }], + }); + await h.run('task_due_reminder', {}, { event: 'schedule' }); + await h.run('task_due_reminder', {}, { event: 'schedule' }); + expect(h.notifications, 'the same task was alerted twice').toHaveLength(1); + }); + + it('skips future, completed and reminder-less tasks', async () => { + const h = await run(); + const byId = Object.fromEntries(h.store.crm_task.map((t) => [t.id, t])); + for (const id of ['t_future', 't_done', 't_none']) { + expect(byId[id].reminder_sent, `${id} was wrongly reminded`).toBeFalsy(); + } + }); +}); + +describe('opportunity_stagnation — daily stalled-deal nudge', () => { + // The sweep predicates on the STORED `stage_entry_date`, not on the + // `days_in_stage` FORMULA — a formula is evaluated after the query, so it + // cannot be a filter key (#489). `stage_entry_date < TODAY() - 14` is the + // same test, resolved by the flow template engine. + const seedOpps = (): Rec[] => [ + { id: 'o_stalled', name: 'Stalled Deal', stage: 'proposal', stage_entry_date: day(-30), owner: 'rep1' }, + { id: 'o_fresh', name: 'Fresh Deal', stage: 'proposal', stage_entry_date: day(-3), owner: 'rep1' }, + // Exactly at the threshold: the filter is `$lt`, so a deal that entered its + // stage exactly 14 days ago must NOT fire. + { id: 'o_boundary', name: 'Boundary Deal', stage: 'proposal', stage_entry_date: day(-14), owner: 'rep1' }, + { id: 'o_won', name: 'Won Deal', stage: 'closed_won', stage_entry_date: day(-99), owner: 'rep1' }, + { id: 'o_lost', name: 'Lost Deal', stage: 'closed_lost', stage_entry_date: day(-99), owner: 'rep1' }, + // A row with a null clock does not satisfy `$lt` and is skipped. + { id: 'o_nullclock', name: 'No Clock', stage: 'proposal', stage_entry_date: null, owner: 'rep1' }, + ]; + + const run = async () => { + const h = makeFlowHarness( + { opportunity_stagnation: OpportunityStagnationFlow }, + { crm_opportunity: seedOpps(), crm_task: [] }, + ); + await h.run('opportunity_stagnation', {}, { event: 'schedule' }); + return h; + }; + + it('selects exactly the open deals past the 14-day threshold', async () => { + const h = await run(); + // The loop body issues one crm_task lookup per selected deal, so those + // lookups are the observable record of what the sweep picked up. + const nudgeLookups = h.queries.filter((q) => q.object === 'crm_task'); + expect(nudgeLookups.map((q) => q.where.related_to_opportunity)).toEqual(['o_stalled']); + }); + + it('skips fresh, boundary and closed deals', async () => { + const h = await run(); + const touched = h.queries + .filter((q) => q.object === 'crm_task') + .map((q) => q.where.related_to_opportunity); + for (const id of ['o_fresh', 'o_boundary', 'o_won', 'o_lost', 'o_nullclock']) { + expect(touched, `${id} should not have been swept`).not.toContain(id); + } + }); +}); + +describe('contract_renewal — daily notice-window sweep', () => { + const contract = (over: Rec): Rec => ({ + id: 'k1', contract_number: 'CTR-1', status: 'activated', crm_account: 'acc1', + owner: 'rep1', contract_value: 90_000, auto_renewal: false, renewal_notice_days: 30, + end_date: day(+20), ...over, + }); + + const run = async (contracts: Rec[], seed: Rec = {}) => { + const h = makeFlowHarness({ contract_renewal: ContractRenewalFlow }, { + crm_contract: contracts, crm_task: [], crm_opportunity: [], ...seed, + }); + await h.run('contract_renewal', {}, { event: 'schedule' }); + return h; + }; + + it('pre-filters to activated contracts ending within the next 120 days', async () => { + // The 120-day span must cover the LARGEST renewal_notice_days in use (seeds + // go to 90). A narrower pre-filter silently truncates the longest notice + // periods, so pin the window itself. + const h = await run([contract({})]); + const q = h.queries.find((x) => x.object === 'crm_contract'); + expect(q, 'the sweep issued no contract query').toBeTruthy(); + expect(q!.where.status).toBe('activated'); + expect(q!.where.end_date).toMatchObject({ $gte: expect.any(String), $lte: expect.any(String) }); + + const span = + (Date.parse(`${String(q!.where.end_date.$lte).slice(0, 10)}T00:00:00Z`) - + Date.parse(`${String(q!.where.end_date.$gte).slice(0, 10)}T00:00:00Z`)) / 86_400_000; + expect(span, 'pre-filter must span 120 days').toBe(120); + }); + + it('selects nothing outside the pre-filter window', async () => { + const h = await run([ + contract({ id: 'k_far', end_date: day(+200) }), + contract({ id: 'k_past', end_date: day(-5) }), + contract({ id: 'k_draft', status: 'draft' }), + ]); + const contractQuery = h.queries.find((x) => x.object === 'crm_contract')!; + const selected = h.store.crm_contract.filter( + (k) => k.status === contractQuery.where.status && + String(k.end_date) >= String(contractQuery.where.end_date.$gte).slice(0, 10) && + String(k.end_date) <= String(contractQuery.where.end_date.$lte).slice(0, 10), + ); + expect(selected).toHaveLength(0); + }); +}); + +/** + * KNOWN DEFECT — conditional edges nested inside a `loop` body never fire. + * + * `AutomationEngine.registerFlow` runs `applyConversionsToFlow`, which rewrites + * a bare string `condition` into a `{ dialect: 'cel', source }` envelope. That + * pass only walks the flow's TOP-LEVEL `edges`; it does not recurse into the + * structured control-flow regions introduced by ADR-0031 (`loop.config.body`). + * + * A condition left as a bare string falls through to the engine's legacy + * template path, which substitutes `{var}` templates (there are none here) and + * then STRING-compares the leftover expression text. So: + * + * 'existingStallTask == null' → 'existingStallTask' === 'null' → false + * + * The gate never opens. Verified against @objectstack/service-automation + * 16.1.0: passing the same expression as an explicit `{dialect:'cel', source}` + * envelope evaluates correctly, which is why every TOP-LEVEL conditional edge + * in this app works and only the loop-nested ones do not. + * + * Three flows carry conditional edges inside a loop body and are therefore + * inert past that gate: `opportunity_stagnation`, `contract_renewal`, and + * `campaign_enrollment`. + * + * These assertions pin the CURRENT behaviour deliberately, so the defect is + * visible and tracked rather than silent. Fixing it — whether by authoring the + * nested conditions as explicit CEL envelopes here, or by making the conversion + * pass recurse upstream — changes what these scheduled sweeps DO in production + * (they would begin creating tasks, opportunities and notifications), so it is + * deliberately NOT bundled into this verification-pipeline change. When it is + * fixed, these three tests go red and must be rewritten to assert the real + * behaviour. + */ +describe('KNOWN DEFECT: loop-nested conditions never evaluate (see comment above)', () => { + it('opportunity_stagnation selects stalled deals but never nudges', async () => { + const h = makeFlowHarness( + { opportunity_stagnation: OpportunityStagnationFlow }, + { + crm_opportunity: [ + { id: 'o_stalled', name: 'Stalled Deal', stage: 'proposal', stage_entry_date: day(-30), owner: 'rep1' }, + ], + crm_task: [], + }, + ); + await h.run('opportunity_stagnation', {}, { event: 'schedule' }); + + // Proof the deal WAS selected — the per-iteration lookup ran… + expect(h.queries.some((q) => q.object === 'crm_task')).toBe(true); + // …but the `existingStallTask == null` gate never opened. + expect(h.store.crm_task, 'gate now opens — rewrite this test').toHaveLength(0); + expect(h.notifications, 'gate now opens — rewrite this test').toHaveLength(0); + }); + + it('contract_renewal selects contracts in window but never books a renewal', async () => { + const h = makeFlowHarness({ contract_renewal: ContractRenewalFlow }, { + crm_contract: [{ + id: 'k1', contract_number: 'CTR-1', status: 'activated', crm_account: 'acc1', + owner: 'rep1', contract_value: 90_000, auto_renewal: true, + renewal_notice_days: 30, end_date: day(+20), + }], + crm_task: [], + crm_opportunity: [], + }); + await h.run('contract_renewal', {}, { event: 'schedule' }); + + expect(h.queries.some((q) => q.object === 'crm_contract')).toBe(true); + // The very first gate (`timestamp(end_date) <= daysFromNow(...)`) compares + // the literal strings 'timestamp(currentContract.end_date)' and + // 'daysFromNow(int(currentContract.renewal_notice_days))', so nothing runs. + expect(h.store.crm_task, 'gate now opens — rewrite this test').toHaveLength(0); + expect(h.store.crm_opportunity, 'gate now opens — rewrite this test').toHaveLength(0); + expect(h.notifications, 'gate now opens — rewrite this test').toHaveLength(0); + }); + + it('an explicit CEL envelope DOES evaluate — this is the available fix', async () => { + // Documents the remedy: the same expression, wrapped, behaves correctly. + const h = makeFlowHarness({}, {}); + const evaluate = (condition: unknown) => + (h.engine as unknown as { + evaluateCondition(c: unknown, v: Map): boolean; + }).evaluateCondition(condition, new Map([['existingStallTask', null]])); + + expect(evaluate('existingStallTask == null'), 'bare string (loop-nested form)').toBe(false); + expect( + evaluate({ dialect: 'cel', source: 'existingStallTask == null' }), + 'explicit CEL envelope', + ).toBe(true); + }); +}); diff --git a/test/helpers/flow-harness.ts b/test/helpers/flow-harness.ts new file mode 100644 index 00000000..f3b4d594 --- /dev/null +++ b/test/helpers/flow-harness.ts @@ -0,0 +1,266 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { AutomationEngine, installBuiltinNodes } from '@objectstack/service-automation'; +import type * as Automation from '@objectstack/spec/automation'; + +type Flow = Automation.Flow; + +/** + * In-memory harness for running REAL flows through the REAL automation engine. + * + * `flow-conversion`, `flow-quote` and `flow-followup` each carried their own + * copy of this data engine, and every copy matched with `===` only. That is + * fine for the record-triggered flows they cover, but it makes the SCHEDULED + * sweeps untestable: every one of them selects with `$lt` / `$nin` / `$lte` + * over a date or status, so an equality-only engine returns an empty set and + * the flow "passes" by doing nothing at all. This engine implements the + * operators, so an empty result set means the flow really selected nothing. + */ + +export type Rec = Record; + +function matchCondition(value: unknown, cond: unknown): boolean { + if (cond !== null && typeof cond === 'object' && !Array.isArray(cond)) { + return Object.entries(cond as Rec).every(([op, operand]) => { + switch (op) { + case '$in': return Array.isArray(operand) && operand.includes(value as never); + case '$nin': return Array.isArray(operand) && !operand.includes(value as never); + case '$ne': return value !== operand; + case '$gt': return compare(value, operand) > 0; + case '$gte': return compare(value, operand) >= 0; + case '$lt': return compare(value, operand) < 0; + case '$lte': return compare(value, operand) <= 0; + default: + throw new Error(`flow-harness: unsupported query operator "${op}"`); + } + }); + } + return value === cond; +} + +/** + * Order two values. Date-shaped strings are compared as instants so that + * `'2026-01-02' > '2026-01-01T23:00:00Z'` behaves the way the driver does; + * everything else falls back to native comparison. + */ +function compare(a: unknown, b: unknown): number { + const da = toTime(a); + const db = toTime(b); + if (da !== undefined && db !== undefined) return da - db; + if (typeof a === 'number' && typeof b === 'number') return a - b; + return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0; +} + +const DATE_ISH = /^\d{4}-\d{2}-\d{2}([T ]|$)/; +function toTime(v: unknown): number | undefined { + if (typeof v !== 'string' || !DATE_ISH.test(v)) return undefined; + const t = Date.parse(v.length === 10 ? `${v}T00:00:00.000Z` : v); + return Number.isNaN(t) ? undefined : t; +} + +export function matches(row: Rec, where: Rec = {}): boolean { + return Object.entries(where).every(([field, cond]) => matchCondition(row[field], cond)); +} + +export interface DataEngine { + store: Record; + insert(object: string, data: Rec): Promise; + find(object: string, opts?: Rec): Promise; + findOne(object: string, opts?: Rec): Promise; + update(object: string, data: Rec, opts?: Rec): Promise<{ modified: number }>; + delete(object: string, opts?: Rec): Promise<{ deleted: number }>; + count(object: string, opts?: Rec): Promise; +} + +export function makeDataEngine(seed: Record = {}): DataEngine { + const store: Record = seed; + let seq = 0; + const rows = (o: string) => (store[o] ??= []); + const whereOf = (opts: Rec = {}) => opts.where ?? opts.filter ?? {}; + + return { + store, + async insert(object, data) { + const rec = { id: data.id ?? `${object}_${++seq}`, ...data }; + rows(object).push(rec); + return rec; + }, + async find(object, opts = {}) { + const hits = rows(object).filter((r) => matches(r, whereOf(opts))); + const limit = opts.limit ?? opts.top; + return typeof limit === 'number' ? hits.slice(0, limit) : hits; + }, + async findOne(object, opts = {}) { + return rows(object).find((r) => matches(r, whereOf(opts))) ?? null; + }, + async update(object, data, opts = {}) { + const affected = rows(object).filter((r) => matches(r, whereOf(opts))); + for (const r of affected) Object.assign(r, data); + return { modified: affected.length }; + }, + async delete(object, opts = {}) { + const list = rows(object); + const doomed = list.filter((r) => matches(r, whereOf(opts))); + for (const r of doomed) list.splice(list.indexOf(r), 1); + return { deleted: doomed.length }; + }, + async count(object, opts = {}) { + return rows(object).filter((r) => matches(r, whereOf(opts))).length; + }, + }; +} + +/** + * A notification captured from a `notify` node. + * + * The builtin notify node calls `messaging.emit({ topic, audience, payload, + * severity, source, actorId, channels })` and — critically — treats a MISSING + * messaging service as `{ success: true, skipped: true }`. So a harness without + * one lets the flow "succeed" while delivering nothing, which is precisely the + * blind spot these tests exist to close. + */ +export interface SentNotification { + /** Resolved recipient list (the node's `to` / `recipients`, interpolated). */ + to: string[]; + title?: unknown; + body?: unknown; + url?: unknown; + severity?: unknown; + topic?: unknown; + channels?: unknown; + [key: string]: unknown; +} + +interface MessagingEmit { + topic?: string; + audience?: string[]; + payload?: Record; + severity?: string; + channels?: string[]; + [key: string]: unknown; +} + +export const silentLogger: any = { + info() {}, warn() {}, error() {}, debug() {}, trace() {}, + child() { return silentLogger; }, +}; + +/** A read a flow issued, captured so tests can assert on selection. */ +export interface RecordedQuery { + op: 'find' | 'findOne' | 'count'; + object: string; + where: Rec; +} + +export interface FlowHarness { + engine: AutomationEngine; + data: DataEngine; + store: Record; + /** Everything a `notify` node emitted during the run. */ + notifications: SentNotification[]; + /** + * Every read the flow issued. For a sweep whose work happens inside a `loop`, + * the per-iteration reads are the only externally visible proof of WHICH + * records the top-level query actually selected. + */ + queries: RecordedQuery[]; + /** Start a flow and return its runId. */ + run(flowName: string, params?: Rec, opts?: Rec): Promise; + /** + * Fire a record-change flow. + * + * `record` / `previous` are TOP-LEVEL keys on the execute context, not + * members of `params` — the engine binds them (and spreads `record`'s own + * fields) into the variable map before evaluating the start condition. Pass + * them through `params` and the condition sees no `record` at all and the + * flow silently reports `skipped: condition_not_met`. + */ + trigger(flowName: string, record: Rec, previous?: Rec): Promise; + /** Resume a paused (screen) run with the given variables. */ + resume(runId: string, variables: Rec): Promise; +} + +/** + * Register `flows` on a fresh engine bound to an in-memory data service. + * + * `notify` nodes are captured rather than delivered: the messaging service is + * out of scope here, and asserting on the captured payload is what proves the + * recipient/severity/template contract (the class of bug where a notify targets + * `{record.owner.manager}` and silently interpolates to "undefined"). + */ +export function makeFlowHarness( + flows: Record, + seed: Record = {}, +): FlowHarness { + const raw = makeDataEngine(seed); + const notifications: SentNotification[] = []; + const queries: RecordedQuery[] = []; + + const record = (op: RecordedQuery['op'], object: string, opts: Rec, result: T): T => { + queries.push({ op, object, where: opts.where ?? opts.filter ?? {} }); + return result; + }; + + const data: DataEngine = { + ...raw, + store: raw.store, + find: async (o, opts = {}) => record('find', o, opts, await raw.find(o, opts)), + findOne: async (o, opts = {}) => record('findOne', o, opts, await raw.findOne(o, opts)), + count: async (o, opts = {}) => record('count', o, opts, await raw.count(o, opts)), + }; + + const messaging = { + async emit(msg: MessagingEmit) { + const { title, body, url, ...rest } = msg.payload ?? {}; + notifications.push({ + to: msg.audience ?? [], + title, body, url, + severity: msg.severity, + topic: msg.topic, + channels: msg.channels, + ...rest, + }); + return { + notificationId: `notif_${notifications.length}`, + delivered: (msg.audience ?? []).length, + failed: 0, + }; + }, + }; + + const ctx: any = { + logger: silentLogger, + getService: (name: string) => { + if (name === 'data' || name === 'objectql') return data; + if (name === 'messaging' || name === 'notification' || name === 'email') return messaging; + return undefined; + }, + }; + + const engine = new AutomationEngine(silentLogger); + installBuiltinNodes(engine, ctx); + for (const [name, flow] of Object.entries(flows)) engine.registerFlow(name, flow); + + return { + engine, + data, + store: data.store, + notifications, + queries, + async run(flowName, params = {}, opts = {}) { + const started: any = await engine.execute(flowName, { + params, userId: 'user_1', event: 'manual', ...opts, + } as any); + return started?.runId ?? started?.run?.id; + }, + async trigger(flowName, record, previous) { + const started: any = await engine.execute(flowName, { + params: {}, userId: 'user_1', event: 'record_change', record, previous, + } as any); + return started?.runId ?? started?.run?.id; + }, + async resume(runId, variables) { + return engine.resume(runId, { variables } as any); + }, + }; +} diff --git a/test/helpers/hook-harness.ts b/test/helpers/hook-harness.ts new file mode 100644 index 00000000..a80ffb55 --- /dev/null +++ b/test/helpers/hook-harness.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { HookApi, HookQuery } from '../../src/objects/_hook-api'; + +/** + * In-memory harness for running REAL hook handlers. + * + * L2 hook bodies are sandboxed code the kernel executes; the metadata-contract + * tests can only prove a hook is *wired*. This harness proves it *behaves*: it + * implements the `ctx.api` surface (`src/objects/_hook-api.ts`) over plain + * arrays, so a test can call `hook.handler(ctx)` with real inputs and assert + * real outputs — no kernel, no server, no timing, nothing flaky. + * + * It supports the query operators the hooks actually use (`$in`, `$nin`, `$ne` + * and the range comparisons). The earlier ad-hoc `makeApi` in + * hooks-runtime.test.ts compared with `===` only, which silently matched + * NOTHING for the `stage: { $nin: [...] }` filters in account/contact + * protection — so those guards could not have been tested against it. + */ + +export type Rec = Record; + +/** A recorded write, for asserting that a hook did (or did not) touch the data. */ +export interface RecordedCall { + op: 'insert' | 'update' | 'updateMany' | 'delete'; + object: string; + args: Rec; +} + +/** Does `value` satisfy a single Mongo-style condition? */ +function matchCondition(value: unknown, cond: unknown): boolean { + if (cond !== null && typeof cond === 'object' && !Array.isArray(cond)) { + return Object.entries(cond as Rec).every(([op, operand]) => { + switch (op) { + case '$in': return Array.isArray(operand) && operand.includes(value as never); + case '$nin': return Array.isArray(operand) && !operand.includes(value as never); + case '$ne': return value !== operand; + case '$gt': return (value as number) > (operand as number); + case '$gte': return (value as number) >= (operand as number); + case '$lt': return (value as number) < (operand as number); + case '$lte': return (value as number) <= (operand as number); + default: + throw new Error(`hook-harness: unsupported query operator "${op}"`); + } + }); + } + return value === cond; +} + +/** Does `row` satisfy every key of `where`? */ +export function matches(row: Rec, where: Rec = {}): boolean { + return Object.entries(where).every(([field, cond]) => matchCondition(row[field], cond)); +} + +/** Project `fields` off a row, when the caller asked for a subset. */ +function project(row: Rec, fields?: string[]): Rec { + if (!fields || fields.length === 0) return row; + const out: Rec = {}; + for (const f of fields) out[f] = row[f]; + // `id` is load-bearing for nearly every caller; never project it away. + if ('id' in row) out.id = row.id; + return out; +} + +export interface Harness { + api: HookApi; + store: Record; + calls: RecordedCall[]; + /** Rows of one object (creating the collection if absent). */ + rows(object: string): Rec[]; + /** Writes recorded against one object, optionally filtered by op. */ + callsFor(object: string, op?: RecordedCall['op']): RecordedCall[]; +} + +/** + * Build a `ctx.api` over `store`. + * + * `find`/`findOne` return the LIVE row objects (not clones) when no `fields` + * projection is requested, so a handler that mutates what it read is visible to + * the test — matching the driver's read-modify-write semantics closely enough + * for these assertions. + */ +export function makeHarness(store: Record = {}): Harness { + const calls: RecordedCall[] = []; + let seq = 0; + const rows = (object: string): Rec[] => (store[object] ??= []); + const whereOf = (q: HookQuery = {}): Rec => (q.filter ?? q.where ?? {}) as Rec; + + const api: HookApi = { + object(name: string) { + return { + async find(q: HookQuery = {}) { + const hits = rows(name).filter((r) => matches(r, whereOf(q))); + const limited = typeof q.top === 'number' ? hits.slice(0, q.top) : hits; + return limited.map((r) => project(r, q.fields)); + }, + async findOne(q: HookQuery = {}) { + const hit = rows(name).find((r) => matches(r, whereOf(q))); + return hit ? project(hit, q.fields) : null; + }, + async count(q: HookQuery = {}) { + return rows(name).filter((r) => matches(r, whereOf(q))).length; + }, + async insert(doc: Rec) { + const rec = { id: doc.id ?? `${name}_${++seq}`, ...doc }; + calls.push({ op: 'insert', object: name, args: rec }); + rows(name).push(rec); + return rec; + }, + async update(id: string, doc: Rec) { + calls.push({ op: 'update', object: name, args: { id, doc } }); + const row = rows(name).find((r) => r.id === id); + if (row) Object.assign(row, doc); + return row; + }, + async updateMany(q: { where: Rec; doc: Rec }) { + calls.push({ op: 'updateMany', object: name, args: q }); + const hits = rows(name).filter((r) => matches(r, q.where)); + for (const r of hits) Object.assign(r, q.doc); + return { modified: hits.length }; + }, + async delete(id: string) { + calls.push({ op: 'delete', object: name, args: { id } }); + const list = rows(name); + const i = list.findIndex((r) => r.id === id); + if (i >= 0) list.splice(i, 1); + return { deleted: i >= 0 ? 1 : 0 }; + }, + }; + }, + }; + + return { + api, + store, + calls, + rows, + callsFor: (object, op) => + calls.filter((c) => c.object === object && (op === undefined || c.op === op)), + }; +} + +/** + * An api whose every read throws — the anonymous / permission-denied shape. + * Hooks that enhance a write (auto-assignment, activity bubbling) must swallow + * this rather than reject the write. + */ +export function makeDeniedApi(message = "Access denied: not 'find'"): HookApi { + const boom = () => { throw new Error(message); }; + return { + object() { + return { + count: boom, find: boom, findOne: boom, + insert: boom, update: boom, updateMany: boom, delete: boom, + } as never; + }, + }; +} + +/** Pick a named hook out of a `*.hook.ts` default export (one hook or many). */ +export function hookNamed(mod: unknown, name: string): Rec { + const list = (Array.isArray(mod) ? mod : [mod]) as Rec[]; + const hook = list.find((h) => h?.name === name); + if (!hook) { + throw new Error( + `hook "${name}" not found (module exports: ${list.map((h) => h?.name).join(', ')})`, + ); + } + return hook; +} + +export interface CtxOptions { + event: string; + input?: Rec; + previous?: Rec; + /** + * The authenticated user. Several hooks treat a MISSING user as the + * "system / seed / backfill write" signal and deliberately relax their + * guards, so tests must be explicit about which they are simulating. + */ + user?: { id: string } | undefined; + api?: HookApi; +} + +/** Build a `HookContext`-shaped object for a handler call. */ +export function makeCtx(opts: CtxOptions): Rec { + return { + event: opts.event, + input: opts.input ?? {}, + previous: opts.previous, + user: opts.user, + api: opts.api, + }; +} + +/** Today as `YYYY-MM-DD`, matching what the hooks stamp. */ +export const today = (): string => new Date().toISOString().slice(0, 10); + +/** `YYYY-MM-DD` `days` from now (negative for the past). */ +export const daysFromNow = (days: number): string => { + const d = new Date(); + d.setDate(d.getDate() + days); + return d.toISOString().slice(0, 10); +}; diff --git a/test/helpers/repo-root.ts b/test/helpers/repo-root.ts new file mode 100644 index 00000000..a7d414f1 --- /dev/null +++ b/test/helpers/repo-root.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { fileURLToPath } from 'node:url'; + +/** + * Absolute path to the repository root, derived from this file's own location. + * + * Tests that read source files off disk previously resolved them relative to + * `process.cwd()` (e.g. `join('src/flows', f)` in docs-drift.test.ts). That is + * only correct when vitest happens to be invoked from the repo root — running + * the suite from a subdirectory, or from an editor/IDE runner with a different + * working directory, turned a real drift check into an ENOENT. Anchoring on + * `import.meta.url` makes the path independent of how the runner was launched. + */ +export const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url)); diff --git a/test/hooks-runtime-sales.test.ts b/test/hooks-runtime-sales.test.ts new file mode 100644 index 00000000..0b0e4ab9 --- /dev/null +++ b/test/hooks-runtime-sales.test.ts @@ -0,0 +1,569 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import accountHooks from '../src/objects/account.hook'; +import contactHooks from '../src/objects/contact.hook'; +import opportunityHooks from '../src/objects/opportunity.hook'; +import productHooks from '../src/objects/product.hook'; +import quoteHooks from '../src/objects/quote.hook'; +import quoteLineItemHooks from '../src/objects/quote_line_item.hook'; +import { + makeHarness, makeCtx, hookNamed, today, daysFromNow, type Rec, +} from './helpers/hook-harness'; + +/** + * Runtime tests for the SALES-side hooks — the real handler bodies, executed + * against a controllable in-memory data layer. + * + * Of the 24 hooks this app registers, only four had runtime coverage + * (`opportunity_amount_rollup`, `opportunity_line_item_price_fill`, + * `quote_total_rollup`, `lead_auto_assign`). Everything asserted below — the + * whole opportunity lifecycle, the closed-record freeze, quote acceptance + * drafting a contract, the delete-protection guards on account / contact / + * product — was previously proven only to be *wired*, never to *work*. + * + * Companion file: hooks-runtime-service.test.ts (case, contract, campaign, + * task, forecast, knowledge, lead). + */ + +const SYSTEM = undefined; // no ctx.user ⇒ system / seed write +const USER = { id: 'user_1' }; // an authenticated human edit + +// ───────────────────────────────────────────────────────── opportunity ── + +describe('opportunity_lifecycle', () => { + const hook = hookNamed(opportunityHooks, 'opportunity_lifecycle'); + + it('derives probability, expected_revenue and forecast_category from stage on insert', async () => { + const input: Rec = { name: 'Deal', amount: 10_000, stage: 'proposal' }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.probability).toBe(60); + expect(input.expected_revenue).toBe(6_000); + expect(input.forecast_category).toBe('commit'); + }); + + it.each([ + ['prospecting', 10, 'pipeline'], + ['qualification', 25, 'pipeline'], + ['needs_analysis', 40, 'best_case'], + ['proposal', 60, 'commit'], + ['negotiation', 80, 'commit'], + ['closed_won', 100, 'closed'], + ['closed_lost', 0, 'omitted'], + ])('stage %s ⇒ probability %i, forecast %s', async (stage, probability, forecast) => { + const input: Rec = { amount: 1_000, stage }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.probability).toBe(probability); + expect(input.expected_revenue).toBe((1_000 * probability) / 100); + expect(input.forecast_category).toBe(forecast); + }); + + it('recomputes expected_revenue when only the amount changes', async () => { + const input: Rec = { amount: 50_000 }; + const previous: Rec = { stage: 'proposal', amount: 10_000, probability: 60 }; + await hook.handler(makeCtx({ event: 'beforeUpdate', input, previous, user: USER })); + expect(input.expected_revenue).toBe(30_000); // 50k × 60% + }); + + it('stamps close_date and restarts the stage clock on the closed_won transition', async () => { + const input: Rec = { stage: 'closed_won' }; + const previous: Rec = { stage: 'negotiation', amount: 25_000, stage_entry_date: '2026-01-01' }; + await hook.handler(makeCtx({ event: 'beforeUpdate', input, previous, user: USER })); + expect(input.close_date).toBe(today()); + // `days_in_stage` is a FORMULA over `stage_entry_date` (#489); re-stamping + // that column IS the reset. It used to write `days_in_stage = 0` against a + // counter nothing ever incremented. + expect(input.stage_entry_date).toBe(today()); + expect(input.probability).toBe(100); + expect(input.expected_revenue).toBe(25_000); + }); + + it('starts the stage clock on insert so a never-moved deal is visible to the sweep', async () => { + const input: Rec = { name: 'New Deal', amount: 1_000, stage: 'prospecting' }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.stage_entry_date).toBe(today()); + }); + + it('leaves the stage clock alone when the stage did not change', async () => { + const input: Rec = { amount: 2_000 }; + const previous: Rec = { stage: 'proposal', amount: 1_000, stage_entry_date: '2026-01-01' }; + await hook.handler(makeCtx({ event: 'beforeUpdate', input, previous, user: USER })); + expect(input.stage_entry_date, 'an amount edit must not reset the stall clock').toBeUndefined(); + }); + + it('does not overwrite an explicitly supplied close_date', async () => { + const input: Rec = { stage: 'closed_won', close_date: '2030-01-01' }; + const previous: Rec = { stage: 'negotiation', amount: 1_000 }; + await hook.handler(makeCtx({ event: 'beforeUpdate', input, previous, user: USER })); + expect(input.close_date).toBe('2030-01-01'); + }); + + it('freezes a closed opportunity against user edits to business fields', async () => { + const previous: Rec = { stage: 'closed_won', amount: 25_000 }; + await expect( + hook.handler(makeCtx({ event: 'beforeUpdate', input: { amount: 1 }, previous, user: USER })), + ).rejects.toThrow(/closed \(closed_won\)/); + }); + + it('allows narrative, approval and system fields on a closed opportunity', async () => { + const previous: Rec = { stage: 'closed_lost', amount: 100 }; + for (const input of [ + { description: 'post-mortem' }, + { next_step: 'nothing' }, + { approval_status: 'approved' }, + { owner: 'user_2' }, + { updated_at: '2026-01-01' }, + ]) { + await expect( + hook.handler(makeCtx({ event: 'beforeUpdate', input, previous, user: USER })), + ).resolves.toBeUndefined(); + } + }); + + it('lets SYSTEM writes through the freeze (re-seed rewrites closed records)', async () => { + // The seed re-evaluates `close_date: daysAgo(15)` on every boot, so a + // re-seed legitimately rewrites closed opportunities. Guarding those threw + // boot-time BodyRunner errors (#459). + const previous: Rec = { stage: 'closed_won', amount: 25_000 }; + await expect( + hook.handler(makeCtx({ + event: 'beforeUpdate', input: { amount: 30_000 }, previous, user: SYSTEM, + })), + ).resolves.toBeUndefined(); + }); + + it('leaves an unknown stage entirely alone rather than guessing', async () => { + const input: Rec = { amount: 1_000, stage: 'not_a_stage' }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.probability).toBeUndefined(); + expect(input.expected_revenue).toBeUndefined(); + expect(input.forecast_category).toBeUndefined(); + }); +}); + +describe('opportunity_promote_account', () => { + const hook = hookNamed(opportunityHooks, 'opportunity_promote_account'); + + it('promotes the account to customer and schedules an activation task on close-won', async () => { + const h = makeHarness({ + crm_account: [{ id: 'acc1', type: 'prospect', owner: 'rep1' }], + crm_task: [], + }); + await hook.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 'opp1', stage: 'closed_won', crm_account: 'acc1', owner: 'rep1' }, + previous: { id: 'opp1', stage: 'negotiation', crm_account: 'acc1' }, + user: USER, + api: h.api, + })); + + expect(h.rows('crm_account')[0].type).toBe('customer'); + const [task] = h.rows('crm_task'); + expect(task, 'no activation task created').toBeTruthy(); + expect(task.priority).toBe('high'); + expect(task.status).toBe('not_started'); + expect(task.related_to_opportunity).toBe('opp1'); + expect(task.owner).toBe('rep1'); + expect(task.due_date).toBe(daysFromNow(3)); + }); + + it('does not re-promote an account that is already a customer', async () => { + const h = makeHarness({ crm_account: [{ id: 'acc1', type: 'customer' }], crm_task: [] }); + await hook.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 'opp1', stage: 'closed_won', crm_account: 'acc1' }, + previous: { id: 'opp1', stage: 'proposal', crm_account: 'acc1' }, + user: USER, + api: h.api, + })); + expect(h.callsFor('crm_account', 'update')).toHaveLength(0); + expect(h.rows('crm_task')).toHaveLength(1); // task still created + }); + + it('is a no-op when the deal did not just become won', async () => { + const h = makeHarness({ crm_account: [{ id: 'acc1', type: 'prospect' }], crm_task: [] }); + for (const [input, previous] of [ + [{ stage: 'proposal', crm_account: 'acc1' }, { stage: 'qualification' }], + [{ stage: 'closed_won', crm_account: 'acc1' }, { stage: 'closed_won' }], // already won + ] as const) { + await hook.handler(makeCtx({ event: 'afterUpdate', input, previous, user: USER, api: h.api })); + } + expect(h.calls).toHaveLength(0); + }); +}); + +// ────────────────────────────────────────────────────────────── quote ── + +describe('quote_workflow', () => { + const hook = hookNamed(quoteHooks, 'quote_workflow'); + + it('defaults expiration_date to quote_date + 30 days on insert', async () => { + const input: Rec = { quote_date: '2026-01-01' }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.expiration_date).toBe('2026-01-31'); + }); + + it('falls back to today + 30 when no quote_date was supplied', async () => { + const input: Rec = {}; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.expiration_date).toBe(daysFromNow(30)); + }); + + it('respects an explicit expiration_date', async () => { + const input: Rec = { quote_date: '2026-01-01', expiration_date: '2026-06-30' }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.expiration_date).toBe('2026-06-30'); + }); + + it.each(['accepted', 'expired'])('freezes a %s quote against user edits', async (status) => { + const previous: Rec = { status, total_price: 100 }; + await expect( + hook.handler(makeCtx({ + event: 'beforeUpdate', input: { total_price: 1 }, previous, user: USER, + })), + ).rejects.toThrow(new RegExp(`Quote is ${status}`)); + }); + + it('allows internal_notes and framework columns on a frozen quote', async () => { + const previous: Rec = { status: 'accepted', total_price: 100 }; + for (const input of [{ internal_notes: 'signed' }, { owner: 'user_2' }, { updated_at: 'x' }]) { + await expect( + hook.handler(makeCtx({ event: 'beforeUpdate', input, previous, user: USER })), + ).resolves.toBeUndefined(); + } + }); + + it('lets SYSTEM writes through the freeze', async () => { + const previous: Rec = { status: 'accepted', total_price: 100 }; + await expect( + hook.handler(makeCtx({ + event: 'beforeUpdate', input: { total_price: 250 }, previous, user: SYSTEM, + })), + ).resolves.toBeUndefined(); + }); +}); + +describe('quote_on_accepted', () => { + const hook = hookNamed(quoteHooks, 'quote_on_accepted'); + + const acceptQuote = async (h: ReturnType, extra: Rec = {}) => + hook.handler(makeCtx({ + event: 'afterUpdate', + input: { + id: 'q1', status: 'accepted', crm_account: 'acc1', crm_contact: 'con1', + crm_opportunity: 'opp1', total_price: 120_000, owner: 'rep1', ...extra, + }, + previous: { id: 'q1', status: 'presented' }, + user: USER, + api: h.api, + })); + + it('drafts a 12-month contract carrying the quote total and links', async () => { + const h = makeHarness({ + crm_contract: [], + crm_opportunity: [{ id: 'opp1', stage: 'proposal' }], + }); + await acceptQuote(h); + + const [contract] = h.rows('crm_contract'); + expect(contract, 'no contract drafted').toBeTruthy(); + expect(contract.status).toBe('draft'); + expect(contract.crm_account).toBe('acc1'); + expect(contract.crm_contact).toBe('con1'); + expect(contract.crm_opportunity).toBe('opp1'); + expect(contract.contract_value).toBe(120_000); + expect(contract.contract_term_months).toBe(12); + expect(contract.owner).toBe('rep1'); + expect(contract.start_date).toBe(today()); + }); + + it('uses real calendar months for end_date, not days × 30', async () => { + // `days * 30` shorted a 12-month term by ~5 days; contract_validation + // tolerates ±1 month so the bug only ever slipped past by luck. + const h = makeHarness({ crm_contract: [], crm_opportunity: [] }); + await acceptQuote(h); + + const { start_date: start, end_date: end } = h.rows('crm_contract')[0]; + const s = new Date(start as string); + const e = new Date(end as string); + const months = + (e.getFullYear() - s.getFullYear()) * 12 + (e.getMonth() - s.getMonth()) + + (e.getDate() >= s.getDate() ? 0 : -1); + expect(months, `${start} → ${end} is not 12 calendar months`).toBe(12); + }); + + it('pushes the linked opportunity to closed_won', async () => { + const h = makeHarness({ + crm_contract: [], + crm_opportunity: [{ id: 'opp1', stage: 'negotiation' }], + }); + await acceptQuote(h); + + const opp = h.rows('crm_opportunity')[0]; + expect(opp.stage).toBe('closed_won'); + expect(opp.close_date).toBe(today()); + }); + + it('never reopens an already-closed opportunity', async () => { + const h = makeHarness({ + crm_contract: [], + crm_opportunity: [{ id: 'opp1', stage: 'closed_lost' }], + }); + await acceptQuote(h); + expect(h.rows('crm_opportunity')[0].stage).toBe('closed_lost'); + expect(h.callsFor('crm_opportunity', 'update')).toHaveLength(0); + }); + + it('is a no-op unless the quote just became accepted', async () => { + const h = makeHarness({ crm_contract: [], crm_opportunity: [] }); + await hook.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 'q1', status: 'accepted' }, + previous: { id: 'q1', status: 'accepted' }, + user: USER, + api: h.api, + })); + expect(h.calls).toHaveLength(0); + }); +}); + +describe('quote_line_item_price_fill', () => { + const hook = hookNamed(quoteLineItemHooks, 'quote_line_item_price_fill'); + + it('stamps list_price and defaults unit_price from the product on insert', async () => { + const h = makeHarness({ crm_product: [{ id: 'p1', list_price: 250 }] }); + const input: Rec = { crm_product: 'p1' }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER, api: h.api })); + expect(input.list_price).toBe(250); + expect(input.unit_price).toBe(250); + }); + + it('re-syncs list_price on update but never clobbers a negotiated unit_price', async () => { + const h = makeHarness({ crm_product: [{ id: 'p1', list_price: 250 }] }); + const input: Rec = { crm_product: 'p1', unit_price: 199 }; + await hook.handler(makeCtx({ event: 'beforeUpdate', input, user: USER, api: h.api })); + expect(input.list_price).toBe(250); + expect(input.unit_price).toBe(199); + }); + + it('is a no-op when no product is chosen or the product has no price', async () => { + const h = makeHarness({ crm_product: [{ id: 'p1' }] }); + const noProduct: Rec = { quantity: 2 }; + await hook.handler(makeCtx({ event: 'beforeInsert', input: noProduct, api: h.api })); + expect(noProduct.list_price).toBeUndefined(); + + const pricelessProduct: Rec = { crm_product: 'p1' }; + await hook.handler(makeCtx({ event: 'beforeInsert', input: pricelessProduct, api: h.api })); + expect(pricelessProduct.list_price).toBeUndefined(); + }); +}); + +// ──────────────────────────────────────────────────────────── account ── + +describe('account_protection', () => { + const hook = hookNamed(accountHooks, 'account_protection'); + + it.each(['example.com', 'ftp://example.com', 'www.example.com'])( + 'rejects the malformed website %s', async (website) => { + await expect( + hook.handler(makeCtx({ event: 'beforeInsert', input: { website }, user: USER })), + ).rejects.toThrow(/must start with http/); + }, + ); + + it.each(['http://example.com', 'https://example.com', 'HTTPS://EXAMPLE.COM'])( + 'accepts the valid website %s', async (website) => { + await expect( + hook.handler(makeCtx({ event: 'beforeInsert', input: { website }, user: USER })), + ).resolves.toBeUndefined(); + }, + ); + + it('rejects negative annual_revenue but allows zero', async () => { + await expect( + hook.handler(makeCtx({ event: 'beforeInsert', input: { annual_revenue: -1 }, user: USER })), + ).rejects.toThrow(/greater than or equal to 0/); + await expect( + hook.handler(makeCtx({ event: 'beforeInsert', input: { annual_revenue: 0 }, user: USER })), + ).resolves.toBeUndefined(); + }); + + it('stamps last_activity_date when a USER changes owner or type', async () => { + for (const input of [{ owner: 'rep2' }, { type: 'customer' }] as Rec[]) { + await hook.handler(makeCtx({ + event: 'beforeUpdate', input, previous: { owner: 'rep1', type: 'prospect' }, user: USER, + })); + expect(input.last_activity_date).toBe(today()); + } + }); + + it('does NOT stamp last_activity_date on a system write', async () => { + // demo_bootstrap claims ownerless seeded accounts as a system write every + // 10 minutes; stamping those flattened every seeded activity date to today + // and emptied the churn report buckets. + const input: Rec = { owner: 'rep2' }; + await hook.handler(makeCtx({ + event: 'beforeUpdate', input, previous: { owner: null }, user: SYSTEM, + })); + expect(input.last_activity_date).toBeUndefined(); + }); + + it('refuses to delete a customer account with open opportunities', async () => { + const h = makeHarness({ + crm_opportunity: [ + { id: 'o1', crm_account: 'acc1', stage: 'proposal' }, + { id: 'o2', crm_account: 'acc1', stage: 'closed_won' }, // closed — ignored + { id: 'o3', crm_account: 'other', stage: 'proposal' }, // other account + ], + }); + await expect( + hook.handler(makeCtx({ + event: 'beforeDelete', + previous: { id: 'acc1', type: 'customer' }, + user: USER, + api: h.api, + })), + ).rejects.toThrow(/1 open opportunity still reference/); + }); + + it('allows deleting a customer account whose opportunities are all closed', async () => { + const h = makeHarness({ + crm_opportunity: [ + { id: 'o1', crm_account: 'acc1', stage: 'closed_won' }, + { id: 'o2', crm_account: 'acc1', stage: 'closed_lost' }, + ], + }); + await expect( + hook.handler(makeCtx({ + event: 'beforeDelete', previous: { id: 'acc1', type: 'customer' }, user: USER, api: h.api, + })), + ).resolves.toBeUndefined(); + }); + + it('only protects customer accounts, not prospects', async () => { + const h = makeHarness({ + crm_opportunity: [{ id: 'o1', crm_account: 'acc1', stage: 'proposal' }], + }); + await expect( + hook.handler(makeCtx({ + event: 'beforeDelete', previous: { id: 'acc1', type: 'prospect' }, user: USER, api: h.api, + })), + ).resolves.toBeUndefined(); + }); +}); + +// ──────────────────────────────────────────────────────────── contact ── + +describe('contact_integrity', () => { + const hook = hookNamed(contactHooks, 'contact_integrity'); + + it('lowercases the email before storing it', async () => { + const h = makeHarness({ crm_contact: [] }); + const input: Rec = { email: 'Ada@Example.COM' }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER, api: h.api })); + expect(input.email).toBe('ada@example.com'); + }); + + it('rejects a duplicate email GLOBALLY, not just within one account', async () => { + // A per-account lookup let a cross-account duplicate sail past the friendly + // check and explode on the DB's global unique index mid-conversion. + const h = makeHarness({ + crm_contact: [{ id: 'c1', email: 'ada@example.com', crm_account: 'accA' }], + }); + await expect( + hook.handler(makeCtx({ + event: 'beforeInsert', + input: { email: 'ADA@example.com', crm_account: 'accB' }, + user: USER, + api: h.api, + })), + ).rejects.toThrow(/already exists/); + }); + + it('does not flag a contact as its own duplicate on update', async () => { + const h = makeHarness({ crm_contact: [{ id: 'c1', email: 'ada@example.com' }] }); + await expect( + hook.handler(makeCtx({ + event: 'beforeUpdate', + input: { email: 'ada@example.com' }, + previous: { id: 'c1', email: 'ada@example.com' }, + user: USER, + api: h.api, + })), + ).resolves.toBeUndefined(); + }); + + it('refuses to delete a contact referenced by open work', async () => { + const h = makeHarness({ + crm_opportunity: [{ id: 'o1', primary_contact: 'c1', stage: 'proposal' }], + crm_quote: [{ id: 'q1', crm_contact: 'c1', status: 'draft' }], + crm_contract: [{ id: 'k1', crm_contact: 'c1', status: 'activated' }], + }); + await expect( + hook.handler(makeCtx({ + event: 'beforeDelete', previous: { id: 'c1' }, user: USER, api: h.api, + })), + ).rejects.toThrow(/still referenced by 1 open opportunity\(ies\), 1 active quote\(s\), 1 active contract\(s\)/); + }); + + it('allows deleting a contact whose references are all settled', async () => { + const h = makeHarness({ + crm_opportunity: [{ id: 'o1', primary_contact: 'c1', stage: 'closed_lost' }], + crm_quote: [{ id: 'q1', crm_contact: 'c1', status: 'rejected' }], + crm_contract: [{ id: 'k1', crm_contact: 'c1', status: 'expired' }], + }); + await expect( + hook.handler(makeCtx({ + event: 'beforeDelete', previous: { id: 'c1' }, user: USER, api: h.api, + })), + ).resolves.toBeUndefined(); + }); +}); + +// ──────────────────────────────────────────────────────────── product ── + +describe('product_catalog', () => { + const hook = hookNamed(productHooks, 'product_catalog'); + + it('rejects a list_price below cost, allows equal', async () => { + await expect( + hook.handler(makeCtx({ event: 'beforeInsert', input: { list_price: 5, cost: 10 } })), + ).rejects.toThrow(/must be greater than or equal to Cost/); + await expect( + hook.handler(makeCtx({ event: 'beforeInsert', input: { list_price: 10, cost: 10 } })), + ).resolves.toBeUndefined(); + }); + + it('compares against the PREVIOUS cost when the update only changes price', async () => { + await expect( + hook.handler(makeCtx({ + event: 'beforeUpdate', input: { list_price: 5 }, previous: { cost: 10 }, + })), + ).rejects.toThrow(/greater than or equal to Cost/); + }); + + it('normalizes sku to uppercase', async () => { + const input: Rec = { sku: 'crm-pro-01' }; + await hook.handler(makeCtx({ event: 'beforeInsert', input })); + expect(input.sku).toBe('CRM-PRO-01'); + }); + + it('refuses to delete a product referenced by any line item, historical included', async () => { + const h = makeHarness({ + crm_opportunity_line_item: [{ id: 'l1', crm_product: 'p1' }], + crm_quote_line_item: [{ id: 'l2', crm_product: 'p1' }, { id: 'l3', crm_product: 'p1' }], + }); + await expect( + hook.handler(makeCtx({ + event: 'beforeDelete', previous: { id: 'p1' }, api: h.api, + })), + ).rejects.toThrow(/referenced by 1 opportunity\(ies\) and 2 quote\(s\)/); + }); + + it('allows deleting an unreferenced product', async () => { + const h = makeHarness({ crm_opportunity_line_item: [], crm_quote_line_item: [] }); + await expect( + hook.handler(makeCtx({ event: 'beforeDelete', previous: { id: 'p1' }, api: h.api })), + ).resolves.toBeUndefined(); + }); +}); diff --git a/test/hooks-runtime-service.test.ts b/test/hooks-runtime-service.test.ts new file mode 100644 index 00000000..c1951304 --- /dev/null +++ b/test/hooks-runtime-service.test.ts @@ -0,0 +1,780 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import campaignHooks from '../src/objects/campaign.hook'; +import caseHooks from '../src/objects/case.hook'; +import contractHooks from '../src/objects/contract.hook'; +import forecastHooks from '../src/objects/forecast.hook'; +import knowledgeHooks from '../src/objects/knowledge_article.hook'; +import leadHooks from '../src/objects/lead.hook'; +import taskHooks from '../src/objects/task.hook'; +import { + makeHarness, makeDeniedApi, makeCtx, hookNamed, today, daysFromNow, type Rec, +} from './helpers/hook-harness'; + +/** + * Runtime tests for the SERVICE / CRM-operations hooks — real handler bodies + * against a controllable in-memory data layer. + * + * Companion file: hooks-runtime-sales.test.ts (opportunity, quote, account, + * contact, product). + */ + +const SYSTEM = undefined; +const USER = { id: 'user_1' }; + +// ─────────────────────────────────────────────────────────────── case ── + +describe('case_sla_defaults', () => { + const hook = hookNamed(caseHooks, 'case_sla_defaults'); + + it('materialises priority_rank so queue views sort by urgency, not alphabetically', async () => { + // Sorting on `priority` itself compares raw strings and inverts urgency + // (medium > low > high > critical). + for (const [priority, rank] of [['low', 1], ['medium', 2], ['high', 3], ['critical', 4]] as const) { + const input: Rec = { priority }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.priority_rank).toBe(rank); + } + }); + + it('gives a critical case a 4-hour SLA when none was supplied', async () => { + const input: Rec = { priority: 'critical' }; + const before = Date.now(); + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + const due = new Date(input.sla_due_date as string).getTime(); + expect(due).toBeGreaterThan(before + 3.9 * 3_600_000); + expect(due).toBeLessThan(before + 4.1 * 3_600_000); + }); + + it('does not give a non-critical case an SLA', async () => { + const input: Rec = { priority: 'high' }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.sla_due_date).toBeUndefined(); + }); + + it('stamps defaults and strips privileged fields on an anonymous guest submission', async () => { + const input: Rec = { + subject: 'Help', owner: 'spoofed', is_escalated: true, is_closed: true, + internal_notes: 'nope', resolution: 'nope', + }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: SYSTEM })); + expect(input.origin).toBe('web'); + expect(input.status).toBe('new'); + expect(input.priority).toBe('medium'); + for (const stripped of ['owner', 'is_escalated', 'is_closed', 'internal_notes', 'resolution']) { + expect(input, `guest submission kept privileged field ${stripped}`).not.toHaveProperty(stripped); + } + }); + + it('derives is_closed from status for trusted writes', async () => { + const closed: Rec = { status: 'closed' }; + await hook.handler(makeCtx({ event: 'beforeUpdate', input: closed, previous: { status: 'new' }, user: USER })); + expect(closed.is_closed).toBe(true); + expect(closed.closed_date, 'closed_date should be stamped on the transition').toBeTruthy(); + + const open: Rec = { status: 'working' }; + await hook.handler(makeCtx({ event: 'beforeUpdate', input: open, previous: { status: 'new' }, user: USER })); + expect(open.is_closed).toBe(false); + }); + + it('computes resolution_time_hours from created → closed', async () => { + const input: Rec = { status: 'closed', closed_date: '2026-01-02T00:00:00.000Z' }; + const previous: Rec = { status: 'working', created_at: '2026-01-01T00:00:00.000Z' }; + await hook.handler(makeCtx({ event: 'beforeUpdate', input, previous, user: USER })); + expect(input.resolution_time_hours).toBe(24); + }); +}); + +describe('case_status_side_effects', () => { + const hook = hookNamed(caseHooks, 'case_status_side_effects'); + + it('creates an urgent task for the account owner on escalation', async () => { + const h = makeHarness({ + crm_account: [{ id: 'acc1', owner: 'rep1' }], + crm_task: [], + }); + await hook.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 'case1', status: 'escalated', crm_account: 'acc1' }, + previous: { id: 'case1', status: 'working', crm_account: 'acc1' }, + user: USER, + api: h.api, + })); + + const [task] = h.rows('crm_task'); + expect(task, 'no escalation task created').toBeTruthy(); + expect(task.priority).toBe('urgent'); + expect(task.owner).toBe('rep1'); + expect(task.related_to_case).toBe('case1'); + expect(task.due_date).toBe(daysFromNow(1)); + }); + + it('bumps account activity on resolve WITHOUT stamping a date on the case', async () => { + // Writing closed_date here as a proxy for "resolved" corrupted resolution + // metrics: a resolved-then-closed case kept its resolve time as its close + // time. closed_date belongs exclusively to the `closed` transition. + const h = makeHarness({ crm_account: [{ id: 'acc1' }], crm_task: [] }); + const input: Rec = { id: 'case1', status: 'resolved', crm_account: 'acc1' }; + await hook.handler(makeCtx({ + event: 'afterUpdate', input, previous: { id: 'case1', status: 'working' }, user: USER, api: h.api, + })); + + expect(h.rows('crm_account')[0].last_activity_date).toBe(today()); + expect(h.callsFor('crm_case')).toHaveLength(0); + expect(input.closed_date).toBeUndefined(); + }); + + it('does not re-fire when the status did not change', async () => { + const h = makeHarness({ crm_account: [{ id: 'acc1', owner: 'rep1' }], crm_task: [] }); + await hook.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 'case1', status: 'escalated', crm_account: 'acc1' }, + previous: { id: 'case1', status: 'escalated', crm_account: 'acc1' }, + user: USER, + api: h.api, + })); + expect(h.calls).toHaveLength(0); + }); +}); + +// ─────────────────────────────────────────────────────────── contract ── + +describe('contract_validation', () => { + const hook = hookNamed(contractHooks, 'contract_validation'); + + it('accepts a date range matching the declared term', async () => { + await expect( + hook.handler(makeCtx({ + event: 'beforeInsert', + input: { start_date: '2026-01-01', end_date: '2027-01-01', contract_term_months: 12 }, + })), + ).resolves.toBeUndefined(); + }); + + it('rejects a term that disagrees with the date range by more than a month', async () => { + await expect( + hook.handler(makeCtx({ + event: 'beforeInsert', + input: { start_date: '2026-01-01', end_date: '2026-07-01', contract_term_months: 12 }, + })), + ).rejects.toThrow(/does not match date range/); + }); + + it('tolerates a one-month rounding difference', async () => { + await expect( + hook.handler(makeCtx({ + event: 'beforeInsert', + input: { start_date: '2026-01-01', end_date: '2026-12-15', contract_term_months: 12 }, + })), + ).resolves.toBeUndefined(); + }); + + it('refuses to shrink end_date after activation', async () => { + await expect( + hook.handler(makeCtx({ + event: 'beforeUpdate', + input: { end_date: '2026-06-01' }, + previous: { status: 'activated', end_date: '2027-01-01', start_date: '2026-01-01' }, + })), + ).rejects.toThrow(/Cannot shrink end_date/); + }); + + it('allows extending end_date after activation', async () => { + await expect( + hook.handler(makeCtx({ + event: 'beforeUpdate', + input: { end_date: '2028-01-01' }, + previous: { status: 'activated', end_date: '2027-01-01', start_date: '2026-01-01', contract_term_months: 24 }, + })), + ).resolves.toBeUndefined(); + }); + + it('allows shrinking end_date while still a draft', async () => { + await expect( + hook.handler(makeCtx({ + event: 'beforeUpdate', + input: { end_date: '2026-06-01' }, + previous: { status: 'draft', end_date: '2027-01-01' }, + })), + ).resolves.toBeUndefined(); + }); +}); + +describe('contract_on_activation', () => { + const hook = hookNamed(contractHooks, 'contract_on_activation'); + + const activate = (h: ReturnType, previous: Rec = {}) => + hook.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 'k1', status: 'activated', crm_account: 'acc1' }, + previous: { id: 'k1', status: 'draft', crm_account: 'acc1', ...previous }, + user: USER, + api: h.api, + })); + + it('stamps signed_date and promotes the account to customer', async () => { + const h = makeHarness({ + crm_contract: [{ id: 'k1', status: 'activated' }], + crm_account: [{ id: 'acc1', type: 'prospect' }], + }); + await activate(h); + expect(h.rows('crm_contract')[0].signed_date).toBe(today()); + expect(h.rows('crm_account')[0].type).toBe('customer'); + }); + + it('does not overwrite an existing signed_date', async () => { + const h = makeHarness({ + crm_contract: [{ id: 'k1', status: 'activated', signed_date: '2025-05-05' }], + crm_account: [{ id: 'acc1', type: 'customer' }], + }); + await activate(h, { signed_date: '2025-05-05' }); + expect(h.callsFor('crm_contract', 'update')).toHaveLength(0); + }); + + it('creates NO renewal task — that belongs to the contract_renewal flow', async () => { + // The activation-time task this hook used to create hardcoded a 60-day + // notice and duplicated the flow, which honours renewal_notice_days. + const h = makeHarness({ + crm_contract: [{ id: 'k1', status: 'activated' }], + crm_account: [{ id: 'acc1', type: 'prospect' }], + crm_task: [], + }); + await activate(h); + expect(h.rows('crm_task')).toHaveLength(0); + }); + + it('is a no-op when the contract was already activated', async () => { + const h = makeHarness({ crm_contract: [{ id: 'k1' }], crm_account: [{ id: 'acc1' }] }); + await hook.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 'k1', status: 'activated' }, + previous: { id: 'k1', status: 'activated' }, + user: USER, + api: h.api, + })); + expect(h.calls).toHaveLength(0); + }); +}); + +// ─────────────────────────────────────────────────────────── campaign ── + +describe('campaign_validation', () => { + const hook = hookNamed(campaignHooks, 'campaign_validation'); + + it('rejects a start_date after the end_date', async () => { + await expect( + hook.handler(makeCtx({ + event: 'beforeInsert', input: { start_date: '2026-06-01', end_date: '2026-01-01' }, + })), + ).rejects.toThrow(/must not be after end_date/); + }); + + it('refuses to move to in_progress without both dates', async () => { + await expect( + hook.handler(makeCtx({ + event: 'beforeUpdate', input: { status: 'in_progress' }, previous: { start_date: '2026-01-01' }, + })), + ).rejects.toThrow(/without both start_date and end_date/); + }); + + it('allows in_progress once both dates are present', async () => { + await expect( + hook.handler(makeCtx({ + event: 'beforeUpdate', + input: { status: 'in_progress' }, + previous: { start_date: '2026-01-01', end_date: '2026-12-31' }, + })), + ).resolves.toBeUndefined(); + }); +}); + +describe('campaign_snapshot_metrics', () => { + const hook = hookNamed(campaignHooks, 'campaign_snapshot_metrics'); + + const complete = (h: ReturnType) => + hook.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 'cmp1', status: 'completed' }, + previous: { id: 'cmp1', status: 'in_progress' }, + user: USER, + api: h.api, + })); + + it('counts leads through the campaign_member junction, not a lead.campaign field', async () => { + // `crm_lead` has NO `campaign` field. The old direct-count queries matched + // nothing, so every lead metric snapshot was silently zero. + const h = makeHarness({ + crm_campaign: [{ id: 'cmp1', status: 'completed' }], + crm_campaign_member: [ + { id: 'm1', crm_campaign: 'cmp1', crm_lead: 'l1', status: 'responded' }, + { id: 'm2', crm_campaign: 'cmp1', crm_lead: 'l2', status: 'sent' }, + { id: 'm3', crm_campaign: 'cmp1', crm_lead: 'l2', status: 'responded' }, // dup lead + { id: 'm4', crm_campaign: 'other', crm_lead: 'l9', status: 'responded' }, + ], + crm_lead: [ + { id: 'l1', is_converted: true }, + { id: 'l2', is_converted: false }, + ], + crm_opportunity: [ + { id: 'o1', crm_campaign: 'cmp1', stage: 'closed_won', amount: 100 }, + { id: 'o2', crm_campaign: 'cmp1', stage: 'proposal', amount: 50 }, + { id: 'o3', crm_campaign: 'cmp1', stage: 'closed_won', amount: 400 }, + ], + }); + await complete(h); + + const campaign = h.rows('crm_campaign')[0]; + expect(campaign.num_leads, 'distinct leads via the junction').toBe(2); + expect(campaign.num_converted_leads).toBe(1); + expect(campaign.num_opportunities).toBe(3); + expect(campaign.num_won_opportunities).toBe(2); + expect(campaign.actual_revenue).toBe(500); + // num_sent is "total members enrolled" — the old `sent || members` form + // under-counted as members progressed past `sent`. + expect(campaign.num_sent).toBe(3); + expect(campaign.num_responses).toBe(2); + }); + + it('snapshots zeroes rather than skipping when a campaign has no members', async () => { + const h = makeHarness({ + crm_campaign: [{ id: 'cmp1', status: 'completed' }], + crm_campaign_member: [], + crm_lead: [], + crm_opportunity: [], + }); + await complete(h); + const campaign = h.rows('crm_campaign')[0]; + expect(campaign.num_leads).toBe(0); + expect(campaign.actual_revenue).toBe(0); + }); + + it('is a no-op unless the campaign just became completed', async () => { + const h = makeHarness({ crm_campaign: [{ id: 'cmp1' }] }); + await hook.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 'cmp1', status: 'completed' }, + previous: { id: 'cmp1', status: 'completed' }, + user: USER, + api: h.api, + })); + expect(h.calls).toHaveLength(0); + }); +}); + +// ─────────────────────────────────────────────────────────── forecast ── + +describe('forecast_derive_period', () => { + const hook = hookNamed(forecastHooks, 'forecast_derive_period'); + + it('derives month end and label', async () => { + const input: Rec = { period: 'month', period_start: '2026-02-01' }; + await hook.handler(makeCtx({ event: 'beforeInsert', input })); + expect(input.period_end).toBe('2026-02-28'); + expect(input.period_label).toBe('Feb 2026'); + }); + + it('handles a leap February', async () => { + const input: Rec = { period: 'month', period_start: '2028-02-01' }; + await hook.handler(makeCtx({ event: 'beforeInsert', input })); + expect(input.period_end).toBe('2028-02-29'); + }); + + it('derives quarter end and label', async () => { + const input: Rec = { period: 'quarter', period_start: '2026-04-01' }; + await hook.handler(makeCtx({ event: 'beforeInsert', input })); + expect(input.period_end).toBe('2026-06-30'); + expect(input.period_label).toBe('Q2 2026'); + }); + + it('defaults period to month and stamps snapshot_date', async () => { + const input: Rec = { period_start: '2026-03-01' }; + await hook.handler(makeCtx({ event: 'beforeInsert', input })); + expect(input.period_end).toBe('2026-03-31'); + expect(input.snapshot_date).toBe(today()); + }); + + it('never overwrites values the caller supplied', async () => { + const input: Rec = { + period: 'month', period_start: '2026-02-01', + period_end: '2026-02-20', period_label: 'Custom', snapshot_date: '2020-01-01', + }; + await hook.handler(makeCtx({ event: 'beforeInsert', input })); + expect(input.period_end).toBe('2026-02-20'); + expect(input.period_label).toBe('Custom'); + expect(input.snapshot_date).toBe('2020-01-01'); + }); + + it('ignores an unparseable period_start instead of writing Invalid Date', async () => { + const input: Rec = { period: 'month', period_start: 'not-a-date' }; + await hook.handler(makeCtx({ event: 'beforeInsert', input })); + expect(input.period_end).toBeUndefined(); + expect(input.period_label).toBeUndefined(); + }); +}); + +// ──────────────────────────────────────────────────── knowledge article ── + +describe('knowledge_article_publish_timestamps', () => { + const hook = hookNamed(knowledgeHooks, 'knowledge_article_publish_timestamps'); + + it('stamps both timestamps on the transition into published', async () => { + const input: Rec = { status: 'published' }; + await hook.handler(makeCtx({ event: 'beforeUpdate', input, previous: { status: 'draft' } })); + expect(input.published_at).toBeTruthy(); + expect(input.last_reviewed_at).toBe(input.published_at); + }); + + it('refreshes only last_reviewed_at when editing an already-published article', async () => { + const input: Rec = { title: 'Revised' }; + await hook.handler(makeCtx({ + event: 'beforeUpdate', input, previous: { status: 'published', published_at: '2020-01-01T00:00:00.000Z' }, + })); + expect(input.published_at, 'republishing must not move the original publish date').toBeUndefined(); + expect(input.last_reviewed_at).toBeTruthy(); + }); + + it('stamps nothing for a draft', async () => { + const input: Rec = { status: 'draft' }; + await hook.handler(makeCtx({ event: 'beforeInsert', input })); + expect(input.published_at).toBeUndefined(); + expect(input.last_reviewed_at).toBeUndefined(); + }); +}); + +// ─────────────────────────────────────────────────────────────── lead ── + +describe('lead_automation', () => { + const hook = hookNamed(leadHooks, 'lead_automation'); + + it('scores a senior contact at a high-value corporate domain highly', async () => { + const input: Rec = { + email: 'cto@acme.io', phone: '+1 555 0100', title: 'CTO', + industry: 'technology', number_of_employees: 500, annual_revenue: 50_000_000, + }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.rating).toBe(5); // 1 + .5 + 1.5 + 1 + .5 + .5 = 5 + }); + + it('scores a bare free-mail lead at the floor', async () => { + const input: Rec = { email: 'someone@gmail.com' }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.rating).toBe(1); + }); + + it('always produces a WHOLE star between 1 and 5', async () => { + // `rating` is a 1-5 star field; half values rendered inconsistently. + const inputs: Rec[] = [ + { email: 'a@acme.io' }, + { email: 'a@acme.io', phone: '555' }, + { title: 'Director' }, + { email: 'a@acme.io', title: 'VP', industry: 'finance' }, + ]; + for (const input of inputs) { + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(Number.isInteger(input.rating), `rating ${input.rating} is not whole`).toBe(true); + expect(input.rating).toBeGreaterThanOrEqual(1); + expect(input.rating).toBeLessThanOrEqual(5); + } + }); + + it('respects an explicitly supplied rating', async () => { + const input: Rec = { email: 'a@gmail.com', rating: 4 }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.rating).toBe(4); + }); + + it('stamps web defaults and strips conversion/ownership fields on an anonymous submission', async () => { + const input: Rec = { + company: 'FromWebForm', is_converted: true, converted_account: 'acc1', + converted_contact: 'c1', converted_opportunity: 'o1', converted_date: '2020-01-01', + owner: 'spoofed', + }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: SYSTEM })); + expect(input.lead_source).toBe('web'); + expect(input.status).toBe('new'); + for (const stripped of [ + 'is_converted', 'converted_account', 'converted_contact', + 'converted_opportunity', 'converted_date', 'owner', + ]) { + expect(input, `public form kept ${stripped}`).not.toHaveProperty(stripped); + } + }); + + it('locks identity fields on a converted lead but leaves notes editable', async () => { + const previous: Rec = { id: 'l1', is_converted: true, company: 'Acme' }; + await expect( + hook.handler(makeCtx({ event: 'beforeUpdate', input: { company: 'Other' }, previous, user: USER })), + ).rejects.toThrow(/Cannot edit a converted lead/); + await expect( + hook.handler(makeCtx({ event: 'beforeUpdate', input: { description: 'note' }, previous, user: USER })), + ).resolves.toBeUndefined(); + }); + + it('lets a SYSTEM write through the converted-lead lock', async () => { + const previous: Rec = { id: 'l1', status: 'converted', company: 'Acme' }; + await expect( + hook.handler(makeCtx({ event: 'beforeUpdate', input: { company: 'Other' }, previous, user: SYSTEM })), + ).resolves.toBeUndefined(); + }); + + it('schedules a follow-up task when a lead becomes qualified', async () => { + const h = makeHarness({ crm_task: [] }); + await hook.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 'l1', status: 'qualified', owner: 'rep1' }, + previous: { id: 'l1', status: 'working' }, + user: USER, + api: h.api, + })); + const [task] = h.rows('crm_task'); + expect(task, 'no follow-up task created').toBeTruthy(); + expect(task.priority).toBe('high'); + expect(task.owner).toBe('rep1'); + expect(task.related_to_lead).toBe('l1'); + expect(task.due_date).toBe(daysFromNow(2)); + }); + + it('does not re-schedule for a lead that was already qualified', async () => { + const h = makeHarness({ crm_task: [] }); + await hook.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 'l1', status: 'qualified' }, + previous: { id: 'l1', status: 'qualified' }, + user: USER, + api: h.api, + })); + expect(h.rows('crm_task')).toHaveLength(0); + }); +}); + +describe('lead_auto_assign — permission resilience', () => { + const hook = hookNamed(leadHooks, 'lead_auto_assign'); + + it('never throws when the rep-pool lookup is denied (anonymous Web-to-Lead)', async () => { + // The public-form grant denies `find` on sys_user_position. The hook must + // swallow that and leave the lead ownerless — NOT reject the insert. + const input: Rec = { company: 'FromWebForm' }; + await expect( + hook.handler(makeCtx({ event: 'beforeInsert', input, api: makeDeniedApi() })), + ).resolves.toBeUndefined(); + expect(input.owner).toBeUndefined(); + }); +}); + +// ─────────────────────────────────────────────────────────────── task ── + +describe('task_completion', () => { + const hook = hookNamed(taskHooks, 'task_completion'); + + it('lands reminder_sent as a real boolean on insert, never NULL', async () => { + const input: Rec = { subject: 'Call' }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.reminder_sent).toBe(false); + }); + + it('materialises priority_rank (urgent outranks high)', async () => { + for (const [priority, rank] of [['low', 1], ['normal', 2], ['high', 3], ['urgent', 4]] as const) { + const input: Rec = { priority }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.priority_rank).toBe(rank); + } + }); + + it('stamps completed_date and 100% progress on the completing transition', async () => { + const input: Rec = { status: 'completed' }; + await hook.handler(makeCtx({ event: 'beforeUpdate', input, previous: { status: 'in_progress' }, user: USER })); + expect(input.completed_date).toBeTruthy(); + expect(input.progress_percent).toBe(100); + expect(input.is_completed).toBe(true); + }); + + it('flags an overdue open task and clears the flag once completed', async () => { + const overdue: Rec = { due_date: daysFromNow(-3), status: 'in_progress' }; + await hook.handler(makeCtx({ event: 'beforeUpdate', input: overdue, previous: {}, user: USER })); + expect(overdue.is_overdue).toBe(true); + + const done: Rec = { due_date: daysFromNow(-3), status: 'completed' }; + await hook.handler(makeCtx({ event: 'beforeUpdate', input: done, previous: { status: 'open' }, user: USER })); + expect(done.is_overdue).toBe(false); + }); + + it('rejects a reminder scheduled after the due date', async () => { + await expect( + hook.handler(makeCtx({ + event: 'beforeInsert', + input: { due_date: '2026-01-01', reminder_date: '2026-02-01' }, + user: USER, + })), + ).rejects.toThrow(/is after the due date/); + }); + + it('accepts a reminder on the due date itself', async () => { + await expect( + hook.handler(makeCtx({ + event: 'beforeInsert', + input: { due_date: '2026-01-01', reminder_date: '2026-01-01T09:00:00.000Z' }, + user: USER, + })), + ).resolves.toBeUndefined(); + }); +}); + +describe('task_recurrence', () => { + const hook = hookNamed(taskHooks, 'task_recurrence'); + + const completeRecurring = (h: ReturnType, previous: Rec) => + hook.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 't1', status: 'completed' }, + previous: { id: 't1', status: 'in_progress', ...previous }, + user: USER, + api: h.api, + })); + + it.each([ + ['daily', 1, '2026-01-01', '2026-01-02'], + ['weekly', 2, '2026-01-01', '2026-01-15'], + // Pins CURRENT behaviour, which is not obviously the desired one: `advanceDate` + // uses `Date.setMonth`, so Jan 31 + 1 month overflows Feb and lands on Mar 3 + // rather than clamping to Feb 28. Worth a follow-up; asserted here so the + // quirk is visible instead of silent. + ['monthly', 1, '2026-01-31', '2026-03-03'], + ['yearly', 1, '2026-03-01', '2027-03-01'], + ])('advances a %s/%i series from %s to %s', async (type, interval, from, to) => { + const h = makeHarness({ crm_task: [] }); + await completeRecurring(h, { + subject: 'Weekly sync', priority: 'normal', is_recurring: true, + recurrence_type: type, recurrence_interval: interval, due_date: from, + }); + const [next] = h.rows('crm_task'); + expect(next, 'no next occurrence spawned').toBeTruthy(); + expect(next.due_date).toBe(to); + }); + + it('spawns the next occurrence as not_started so it cannot re-trigger itself', async () => { + const h = makeHarness({ crm_task: [] }); + await completeRecurring(h, { + subject: 'Weekly sync', is_recurring: true, recurrence_type: 'weekly', + recurrence_interval: 1, due_date: daysFromNow(0), owner: 'rep1', + }); + const [next] = h.rows('crm_task'); + expect(next.status).toBe('not_started'); + expect(next.is_completed).toBe(false); + expect(next.reminder_sent).toBe(false); + expect(next.subject).toBe('Weekly sync'); + expect(next.owner).toBe('rep1'); + expect(next.is_recurring).toBe(true); + }); + + it('advances the reminder alongside the due date', async () => { + const h = makeHarness({ crm_task: [] }); + await completeRecurring(h, { + subject: 'Sync', is_recurring: true, recurrence_type: 'daily', recurrence_interval: 1, + due_date: '2026-01-01', reminder_date: '2026-01-01T09:00:00.000Z', + }); + expect(h.rows('crm_task')[0].reminder_date).toContain('2026-01-02'); + }); + + it('stops the series once the next occurrence would pass recurrence_end_date', async () => { + const h = makeHarness({ crm_task: [] }); + await completeRecurring(h, { + subject: 'Sync', is_recurring: true, recurrence_type: 'weekly', recurrence_interval: 1, + due_date: '2026-01-01', recurrence_end_date: '2026-01-05', + }); + expect(h.rows('crm_task')).toHaveLength(0); + }); + + it('ignores non-recurring tasks and unknown recurrence types', async () => { + const h = makeHarness({ crm_task: [] }); + await completeRecurring(h, { subject: 'One-off', due_date: '2026-01-01' }); + await completeRecurring(h, { + subject: 'Odd', is_recurring: true, recurrence_type: 'fortnightly', due_date: '2026-01-01', + }); + expect(h.rows('crm_task')).toHaveLength(0); + }); + + it('does not spawn on an edit to an already-completed task', async () => { + const h = makeHarness({ crm_task: [] }); + await hook.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 't1', description: 'tweak' }, + previous: { + id: 't1', status: 'completed', is_recurring: true, + recurrence_type: 'daily', recurrence_interval: 1, due_date: '2026-01-01', + }, + user: USER, + api: h.api, + })); + expect(h.rows('crm_task')).toHaveLength(0); + }); +}); + +describe('task_activity_bubble', () => { + const hook = hookNamed(taskHooks, 'task_activity_bubble'); + + it('bubbles last_activity_date to a related account', async () => { + const h = makeHarness({ crm_account: [{ id: 'acc1' }] }); + await hook.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 't1', related_to_type: 'crm_account', related_to_account: 'acc1' }, + previous: { id: 't1' }, + user: USER, + api: h.api, + })); + expect(h.rows('crm_account')[0].last_activity_date).toBe(today()); + }); + + it('uses last_contacted_date for a lead, which has no last_activity_date', async () => { + const h = makeHarness({ crm_lead: [{ id: 'l1' }] }); + await hook.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 't1', related_to_type: 'crm_lead', related_to_lead: 'l1' }, + previous: { id: 't1' }, + user: USER, + api: h.api, + })); + const lead = h.rows('crm_lead')[0]; + expect(lead.last_activity_date, 'crm_lead has no last_activity_date column').toBeUndefined(); + expect(typeof lead.last_contacted_date).toBe('string'); + }); + + it.each(['crm_contact', 'crm_opportunity', 'crm_case'])( + 'writes nothing for %s, which carries no activity timestamp', async (type) => { + const h = makeHarness({ [type]: [{ id: 'x1' }] }); + const refField = { + crm_contact: 'related_to_contact', + crm_opportunity: 'related_to_opportunity', + crm_case: 'related_to_case', + }[type]!; + await hook.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 't1', related_to_type: type, [refField]: 'x1' }, + previous: { id: 't1' }, + user: USER, + api: h.api, + })); + expect(h.calls).toHaveLength(0); + }, + ); + + it('is a no-op with no parent, and never propagates a write failure', async () => { + const h = makeHarness({}); + await hook.handler(makeCtx({ + event: 'afterUpdate', input: { id: 't1' }, previous: { id: 't1' }, user: USER, api: h.api, + })); + expect(h.calls).toHaveLength(0); + + // A denied write must be swallowed — the bubble is best-effort and must + // never break the parent task write. + await expect( + hook.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 't1', related_to_type: 'crm_account', related_to_account: 'acc1' }, + previous: { id: 't1' }, + user: USER, + api: makeDeniedApi('denied'), + })), + ).resolves.toBeUndefined(); + }); +}); diff --git a/test/labeler-config.test.ts b/test/labeler-config.test.ts new file mode 100644 index 00000000..7d3885ab --- /dev/null +++ b/test/labeler-config.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { REPO_ROOT } from './helpers/repo-root'; + +/** + * `.github/labeler.yml` drift guard. + * + * A labeler rule whose glob matches nothing is invisible: the workflow stays + * green, the PR just never gets the label. Before this test, three of the seven + * rules were in exactly that state — `backend` pointed at `src/server.ts`, + * `src/engine/**` and `src/triggers/**`, `ui` at `src/ui/**` and + * `tailwind.config.cjs`, and `metadata` at `src/metadata/**`, none of which + * this repo has ever contained. + * + * The config is deliberately parsed with a regex rather than a YAML dependency: + * the file is a flat `label: → changed-files: → any-glob-to-any-file: [...]` + * shape, and keeping the guard dependency-free means it can never be the reason + * the suite is skipped. + */ + +const CONFIG = join(REPO_ROOT, '.github/labeler.yml'); + +/** Directories that hold no first-party, PR-diffable files. */ +const SKIP_DIRS = new Set([ + 'node_modules', 'dist', '.next', '.source', '.objectstack', '.git', + 'test-results', 'playwright-report', +]); + +/** Every repo-relative file path, POSIX-separated. */ +function walk(dir = ''): string[] { + const out: string[] = []; + for (const entry of readdirSync(join(REPO_ROOT, dir), { withFileTypes: true })) { + if (SKIP_DIRS.has(entry.name)) continue; + const rel = dir ? `${dir}/${entry.name}` : entry.name; + if (entry.isDirectory()) out.push(...walk(rel)); + else if (entry.isFile()) out.push(rel); + } + return out; +} + +/** + * Minimal minimatch subset covering the syntax this config uses: + * `**` spans separators, `*` and `?` do not. + */ +function globToRegExp(glob: string): RegExp { + let re = ''; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i++; + if (glob[i + 1] === '/') i++; // `docs/**/x` → `docs/` already consumed + } else { + re += '[^/]*'; + } + } else if (c === '?') re += '[^/]'; + else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + } + return new RegExp(`^${re}$`); +} + +/** label → globs, straight out of the YAML. */ +function parseLabelerConfig(): Map { + const text = readFileSync(CONFIG, 'utf8'); + const rules = new Map(); + let current: string | null = null; + + for (const raw of text.split('\n')) { + const line = raw.replace(/\r$/, ''); + if (!line.trim() || line.trim().startsWith('#')) continue; + + // A top-level `label:` key (column 0, no leading dash). + const label = /^([^\s#][^:]*):\s*$/.exec(line); + if (label) { + current = label[1].trim(); + rules.set(current, []); + continue; + } + + const globs = /any-glob-to-any-file:\s*\[(.*)\]\s*$/.exec(line); + if (globs && current) { + const list = globs[1] + .split(',') + .map((s) => s.trim().replace(/^['"]|['"]$/g, '')) + .filter(Boolean); + rules.get(current)!.push(...list); + } + } + return rules; +} + +const rules = parseLabelerConfig(); +const files = walk(); + +/** + * The labels that exist in the repository. `actions/labeler` does not create + * labels — a key with no matching repo label silently applies nothing, which is + * how the old `ui:` rule went unnoticed. Adding a key to labeler.yml means + * creating the label in repo settings and listing it here. + */ +const REPO_LABELS = new Set([ + 'documentation', 'dependencies', 'ci/cd', 'backend', 'metadata', 'configuration', +]); + +describe('.github/labeler.yml', () => { + it('parses into a non-trivial rule set', () => { + expect(rules.size, 'no label rules parsed — the parser or the file shape changed').toBeGreaterThan(0); + for (const [label, globs] of rules) { + expect(globs.length, `label '${label}' has no globs`).toBeGreaterThan(0); + } + }); + + it('only uses labels that exist in the repository', () => { + const unknown = [...rules.keys()].filter((l) => !REPO_LABELS.has(l)); + expect( + unknown, + `labeler.yml references label(s) that do not exist: ${unknown.join(', ')}. ` + + 'Create them in repo settings and add them to REPO_LABELS, or drop the rule.', + ).toEqual([]); + }); + + it('every glob matches at least one file in the tree', () => { + const dead: string[] = []; + for (const [label, globs] of rules) { + for (const glob of globs) { + const re = globToRegExp(glob); + if (!files.some((f) => re.test(f))) dead.push(`${label} → ${glob}`); + } + } + expect( + dead, + `labeler globs that match nothing (the label can never be applied):\n ${dead.join('\n ')}`, + ).toEqual([]); + }); + + it('detects a dead glob (guards the guard)', () => { + // The exact patterns the old config shipped. If these ever start matching, + // the check above has stopped being able to tell the difference. + for (const dead of ['src/server.ts', 'src/engine/**', 'src/ui/**', 'tailwind.config.cjs']) { + const re = globToRegExp(dead); + expect(files.some((f) => re.test(f)), `${dead} unexpectedly matches`).toBe(false); + } + // …and that a live pattern does match, so the matcher isn't simply broken. + expect(files.some((f) => globToRegExp('src/objects/*.hook.ts').test(f))).toBe(true); + expect(files.some((f) => globToRegExp('docs/**').test(f))).toBe(true); + expect(files.some((f) => globToRegExp('*.md').test(f))).toBe(true); + }); +}); diff --git a/test/metadata-references.test.ts b/test/metadata-references.test.ts index 4cc899d4..0ba3607b 100644 --- a/test/metadata-references.test.ts +++ b/test/metadata-references.test.ts @@ -891,7 +891,7 @@ describe('every named list view is reachable', () => { for (const v of views) { const defined = new Set([ v.list?.name, - ...Object.entries(v.listViews ?? {}).map(([key, def]: [string, AnyRec]) => def.name ?? key), + ...Object.entries(v.listViews ?? {}).map(([key, def]) => (def as AnyRec)?.name ?? key), ].filter(Boolean)); for (const t of v.list?.tabs ?? []) { if (t.view && !defined.has(t.view)) { diff --git a/test/runtime-coverage.test.ts b/test/runtime-coverage.test.ts new file mode 100644 index 00000000..3082732b --- /dev/null +++ b/test/runtime-coverage.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { REPO_ROOT } from './helpers/repo-root'; +import { allHooks } from '../src/hooks'; +import * as flows from '../src/flows'; + +/** + * Runtime-coverage guard. + * + * The gap this closes is the one that let 20 of 24 hooks and 17 of 20 flows + * ship with no runtime test at all: nothing anywhere failed when a new hook or + * flow arrived untested. `os validate` proves a hook is WIRED; the + * metadata-contract suites prove it is SHAPED correctly; only a runtime test + * proves it BEHAVES. + * + * Line coverage cannot enforce this on its own. A flow file is an object + * literal, so importing it scores 100% whether or not any test ever executes + * the flow — which is exactly why `vitest.config.ts` scopes v8 coverage to the + * hook handlers and defers flow coverage to this structural check. + * + * The rule: every registered hook name and every registered flow name must + * appear somewhere in a runtime test file. That is a deliberately low bar — + * naming a flow is not the same as testing it well — but it is a bar that + * cannot be cleared by accident, and it makes a new untested hook or flow fail + * CI on the PR that adds it. + */ + +/** Test files that execute real handler/flow code (as opposed to reading metadata). */ +const RUNTIME_TEST_FILES = [ + 'hooks-runtime.test.ts', + 'hooks-runtime-sales.test.ts', + 'hooks-runtime-service.test.ts', + 'flow-conversion.test.ts', + 'flow-quote.test.ts', + 'flow-followup.test.ts', + 'flow-scheduled.test.ts', + 'flow-record-change.test.ts', + 'flow-case-actions.test.ts', +]; + +/** + * Flows knowingly still without a runtime test. + * + * This list may only ever SHRINK. Adding to it is a conscious admission, not a + * shortcut — and a stale entry (a flow that has since gained coverage) fails + * the suite too, so it cannot rot. + */ +const PENDING_FLOWS = new Set([ + // Screen flow driven entirely from the console's campaign UI; its loop-nested + // enrolment gate is inert for the reason documented in flow-scheduled.test.ts, + // so a behavioural test would only pin that defect a third time. + 'campaign_enrollment', + // Spans a 24h `wait` node; needs timer-resume support in the harness. + 'case_csat_followup', + // First-boot demo seeding. Exercised end to end every time the e2e suite + // boots a cold database (e2e/smoke.spec.ts asserts the seeded records). + 'demo_bootstrap', +]); + +const testSource = (() => { + const dir = join(REPO_ROOT, 'test'); + const present = new Set(readdirSync(dir)); + const missing = RUNTIME_TEST_FILES.filter((f) => !present.has(f)); + if (missing.length) { + throw new Error( + `runtime-coverage guard is stale: ${missing.join(', ')} no longer exist(s). ` + + 'Update RUNTIME_TEST_FILES — a guard reading files that are gone checks nothing.', + ); + } + // Comments are stripped before matching. Several of these files DISCUSS + // flows they do not exercise (the loop-nested-condition defect note names + // three), and a name that appears only in prose is not coverage. + return RUNTIME_TEST_FILES.map((f) => readFileSync(join(dir, f), 'utf8')) + .join('\n') + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/(^|[^:])\/\/.*$/gm, '$1'); +})(); + +const hookNames = allHooks.map((h) => h.name).filter(Boolean) as string[]; +const flowNames = Object.values(flows as Record) + .filter((f): f is { name: string } => + typeof f === 'object' && f !== null && typeof (f as { name?: unknown }).name === 'string') + .map((f) => f.name); + +describe('runtime coverage', () => { + it('sees a non-trivial set of hooks and flows', () => { + // Guards the guard: if the barrels ever stop exporting, the assertions + // below would pass vacuously. + expect(hookNames.length, 'no hooks discovered').toBeGreaterThanOrEqual(20); + expect(flowNames.length, 'no flows discovered').toBeGreaterThanOrEqual(20); + }); + + it('every registered hook is named in a runtime test', () => { + const untested = hookNames.filter((name) => !testSource.includes(name)); + expect( + untested, + `hooks with no runtime test:\n ${untested.join('\n ')}\n` + + 'Add cases to test/hooks-runtime-*.test.ts — `os validate` only proves the hook is wired.', + ).toEqual([]); + }); + + it('every registered flow is named in a runtime test', () => { + const untested = flowNames + .filter((name) => !PENDING_FLOWS.has(name)) + .filter((name) => !testSource.includes(name)); + expect( + untested, + `flows with no runtime test:\n ${untested.join('\n ')}\n` + + 'Add cases to test/flow-*.test.ts, or add the flow to PENDING_FLOWS with a reason.', + ).toEqual([]); + }); + + it('the pending list contains no stale entries', () => { + const stale = [...PENDING_FLOWS].filter((name) => testSource.includes(name)); + expect( + stale, + `these flows now HAVE runtime tests — remove them from PENDING_FLOWS: ${stale.join(', ')}`, + ).toEqual([]); + }); + + it('the pending list only names flows that exist', () => { + const ghosts = [...PENDING_FLOWS].filter((name) => !flowNames.includes(name)); + expect(ghosts, `PENDING_FLOWS names non-existent flow(s): ${ghosts.join(', ')}`).toEqual([]); + }); +}); diff --git a/test/smoke.test.ts b/test/smoke.test.ts index 14e7dc2d..dd4dd4a2 100644 --- a/test/smoke.test.ts +++ b/test/smoke.test.ts @@ -18,9 +18,14 @@ const flows: any[] = (stack as any).flows ?? []; describe('hotcrm metadata bundle', () => { it('exposes the expected manifest', () => { - expect(stack.manifest.id).toBe('app.objectstack.hotcrm'); - expect(stack.manifest.namespace).toBe('crm'); - expect(stack.manifest.type).toBe('app'); + const manifest = stack.manifest; + // `manifest` is optional on the stack type; assert its presence first so a + // stack that shipped without one fails here by name rather than as an + // unhelpful "cannot read property of undefined" three lines down. + expect(manifest, 'stack declares no manifest').toBeDefined(); + expect(manifest!.id).toBe('app.objectstack.hotcrm'); + expect(manifest!.namespace).toBe('crm'); + expect(manifest!.type).toBe('app'); }); it('registers the core CRM objects', () => { diff --git a/tsconfig.json b/tsconfig.json index e283ad4a..6636156e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,14 +1,39 @@ { "compilerOptions": { "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", + // Nothing here is ever emitted by tsc — `objectstack build` bundles src via + // esbuild and vitest transpiles the suites — so the module mode only needs + // to describe how this code is *consumed*. `preserve` + `bundler` does + // that: extensionless relative imports (which the whole tree uses) resolve, + // and `import.meta` is legal, which `NodeNext` rejected for any file that + // would notionally emit to CommonJS. + "module": "preserve", + "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, - "outDir": "./dist", - "rootDir": "." + "rootDir": ".", + // `pnpm typecheck` is `tsc --noEmit`, but `outDir: "./dist"` was still + // declared — and `dist/` is where `objectstack build` writes the artifact + // that `scripts/publish-marketplace.mjs` uploads. Any `tsc` invocation + // without `--noEmit` would have scattered .js next to the build output that + // ships to the marketplace. Pinning `noEmit` here removes the footgun + // regardless of how tsc is invoked. + "noEmit": true, + "types": ["node"] }, - "include": ["src/**/*", "objectstack.config.ts"] -} \ No newline at end of file + // `test/`, `e2e/` and `scripts/` were all outside `include`, so ~1,900 lines + // of first-party TypeScript — the whole vitest suite, both playwright specs, + // and the 606-line analytics-reconcile tool — were never typechecked by CI. + "include": [ + "src/**/*", + "test/**/*", + "e2e/**/*", + "scripts/**/*.ts", + "objectstack.config.ts", + "vitest.config.ts", + "playwright.config.ts" + ], + "exclude": ["node_modules", "dist", "apps"] +} diff --git a/vitest.config.ts b/vitest.config.ts index 7ed044a4..7227b370 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,5 +11,36 @@ export default defineConfig({ test: { include: ['test/**/*.test.ts'], exclude: ['e2e/**', 'node_modules/**', 'dist/**'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + reportsDirectory: './coverage', + /** + * Scoped to the hook handlers — the only part of `src/` that is + * executable code. Everything else (objects, views, seed data, + * translations) is declarative metadata whose line-coverage number is + * pure noise, and including it diluted the signal enough to hide the + * fact that 20 of 24 hooks had no runtime test at all. + * + * `src/flows/*.flow.ts` is deliberately NOT here for the same reason, + * and for a sharper one: a flow file is an object literal, so importing + * it scores 100% whether or not any test ever executes the flow. Flow + * coverage is asserted structurally instead — see + * `test/runtime-coverage.test.ts`, which fails when a registered flow or + * hook has no runtime test naming it. + */ + include: ['src/objects/*.hook.ts'], + /** + * Floors sit just under the measured numbers, so they lock in the + * runtime-test work rather than aspiring to it. Raise them as coverage + * improves; never lower them to turn a red build green. + */ + thresholds: { + lines: 95, // measured 99.6 + statements: 92, // measured 94.9 + functions: 92, // measured 95.3 + branches: 78, // measured 80.2 + }, + }, }, });