|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #24289 — `bindAppArtifactHandlers` hands a hook's inline `handler` function |
| 5 | + * the `ctx.title(field?)` a hook `body` gets, through the one accessor |
| 6 | + * (`buildTitleSurface`), on a REAL `ObjectQL` engine dispatching through its own |
| 7 | + * `triggerHooks`. |
| 8 | + * |
| 9 | + * The cross-door answer (a source boot against the artifact's body, on a booted |
| 10 | + * stack) is pinned in `@objectstack/verify`'s `harness.hook-title-door.test.ts`. |
| 11 | + * This file pins what only the binder can show: |
| 12 | + * |
| 13 | + * - both forms answer, and an absent title is `null`, as the VM delivers it; |
| 14 | + * - every write the handler makes lands on the engine's own context, while |
| 15 | + * that context never gains a `title` key; |
| 16 | + * - the app's declaration keeps its own function. |
| 17 | + * |
| 18 | + * ⚠️ `@objectstack/objectql` resolves to its `dist/` here, and so do the |
| 19 | + * record-title imports inside `body-runner.ts`: build it first. |
| 20 | + */ |
| 21 | + |
| 22 | +import { describe, it, expect } from 'vitest'; |
| 23 | +import { ObjectQL } from '@objectstack/objectql'; |
| 24 | +import { bindAppArtifactHandlers } from './app-artifact-handlers.js'; |
| 25 | + |
| 26 | +const APP_ID = 'com.example.hook-titles'; |
| 27 | +const quiet = { debug() {}, info() {}, warn() {}, error() {} }; |
| 28 | + |
| 29 | +const PRODUCT = { name: 'ttl_product', nameField: 'name', fields: { name: { type: 'text' } } }; |
| 30 | +const LINE = { |
| 31 | + name: 'ttl_line', |
| 32 | + nameField: 'label', |
| 33 | + fields: { |
| 34 | + name: { type: 'text' }, |
| 35 | + product: { type: 'lookup', reference: 'ttl_product' }, |
| 36 | + spare: { type: 'lookup', reference: 'ttl_product' }, |
| 37 | + label: { type: 'formula', expression: "'#' + record.name" }, |
| 38 | + }, |
| 39 | +}; |
| 40 | + |
| 41 | +function engine(): ObjectQL { |
| 42 | + const ql = new ObjectQL({ logger: quiet } as any); |
| 43 | + for (const o of [PRODUCT, LINE]) ql.registry.registerObject(o as any, 'test'); |
| 44 | + return ql; |
| 45 | +} |
| 46 | + |
| 47 | +/** The engine's own hook context shape: the payload wrapped under `input.data`, and the scoped `api`. */ |
| 48 | +function hookContext(event: string, reads: string[]) { |
| 49 | + return { |
| 50 | + object: 'ttl_line', |
| 51 | + event, |
| 52 | + input: { data: { name: 'n1', product: 'p1' } }, |
| 53 | + session: { isSystem: true }, |
| 54 | + api: { |
| 55 | + object: (name: string) => ({ |
| 56 | + findOne: async (q: { where: { id: string } }) => { |
| 57 | + reads.push(`${name}:${q.where.id}`); |
| 58 | + return { id: q.where.id, name: 'Widget' }; |
| 59 | + }, |
| 60 | + }), |
| 61 | + }, |
| 62 | + } as Record<string, any>; |
| 63 | +} |
| 64 | + |
| 65 | +describe('#24289: bindAppArtifactHandlers hands an inline hook handler ctx.title', () => { |
| 66 | + it('answers both forms through the one accessor; an absent title is null, never an id', async () => { |
| 67 | + const ql = engine(); |
| 68 | + const seen: unknown[] = []; |
| 69 | + bindAppArtifactHandlers(ql as any, { |
| 70 | + manifest: { id: APP_ID, version: '0.1.0', type: 'app' }, |
| 71 | + hooks: [{ |
| 72 | + name: 'ttl_name_line', |
| 73 | + object: 'ttl_line', |
| 74 | + events: ['beforeInsert'], |
| 75 | + handler: async (ctx: any) => { |
| 76 | + seen.push(await ctx.title(), await ctx.title('product'), await ctx.title('spare'), await ctx.title(null)); |
| 77 | + }, |
| 78 | + }], |
| 79 | + }, { appId: APP_ID, logger: quiet }); |
| 80 | + |
| 81 | + const reads: string[] = []; |
| 82 | + await ql.triggerHooks('beforeInsert', hookContext('beforeInsert', reads) as any); |
| 83 | + |
| 84 | + // This record's formula title over its own columns; the related record's |
| 85 | + // title off the one read the hook context's own `api` served. |
| 86 | + expect(seen).toEqual(['#n1', 'Widget', null, '#n1']); |
| 87 | + expect(reads).toEqual(['ttl_product:p1']); |
| 88 | + }); |
| 89 | + |
| 90 | + it("lands every write on the engine's context, which never gains a title key", async () => { |
| 91 | + const ql = engine(); |
| 92 | + const handler = async (ctx: any) => { |
| 93 | + ctx.input.stamped = String(await ctx.title()); |
| 94 | + ctx.result = { replaced: true }; |
| 95 | + }; |
| 96 | + const hook = { name: 'ttl_stamp_line', object: 'ttl_line', events: ['beforeInsert'], handler }; |
| 97 | + bindAppArtifactHandlers(ql as any, { manifest: { id: APP_ID, version: '0.1.0', type: 'app' }, hooks: [hook] }, { |
| 98 | + appId: APP_ID, |
| 99 | + logger: quiet, |
| 100 | + }); |
| 101 | + |
| 102 | + const ctx = hookContext('beforeInsert', []); |
| 103 | + await ql.triggerHooks('beforeInsert', ctx as any); |
| 104 | + |
| 105 | + expect(ctx.input.data.stamped).toBe('#n1'); |
| 106 | + expect(ctx.result).toEqual({ replaced: true }); |
| 107 | + expect('title' in ctx).toBe(false); |
| 108 | + expect(hook.handler, "the app's own declaration is not rewritten").toBe(handler); |
| 109 | + }); |
| 110 | +}); |
0 commit comments