From 52530d521b6a1e936dcc0b5ecac49b14e39b608d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 08:36:13 +0000 Subject: [PATCH] fix(actions): resolve the declared nameField for record_label, unify the log twins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `log_call` / `log_meeting` stamped `record_label: ctx.record?.name`, but `name` is not the display field on almost anything here: 14 of 15 objects declare a different `nameField` (`display_title`, `full_name`, `subject`, `contract_number`, …) and most have no `name` column at all — including `crm_case`, the object both actions are scoped to. Every logged call and meeting therefore landed on the timeline with a null label. The bodies now resolve the object's declared `nameField` through a map derived from the object definitions, so it cannot drift and it survives a restore of the global design these actions document. The two actions were also near-verbatim copies that had quietly stopped agreeing on whether `duration` is required (yes for calls, no for meetings, undocumented either way). Everything they share — body, dispatch declarations, the subject/duration/notes param core — now comes from one builder, and `duration` is optional on both, which is what the shared body was already written for: its `duration ? … : subject` summary branch was unreachable while the field was mandatory. Adds `test/global-actions.test.ts`, which EXECUTES the bodies instead of regex-matching them (a new file rather than an append to the high-traffic `test/metadata-references.test.ts`). It asserts the label resolves for every object declaring a `nameField`, and that the twins emit the same activity row apart from the summary prefix and per-kind metadata. Five of its assertions fail against the pre-fix source. Refs #514. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016YKtPTGAZEycQ2bt4BmtpK --- ...activity-actions-record-label-and-twins.md | 26 ++ src/actions/global.actions.ts | 268 ++++++++++------- test/global-actions.test.ts | 274 ++++++++++++++++++ 3 files changed, 458 insertions(+), 110 deletions(-) create mode 100644 .changeset/activity-actions-record-label-and-twins.md create mode 100644 test/global-actions.test.ts diff --git a/.changeset/activity-actions-record-label-and-twins.md b/.changeset/activity-actions-record-label-and-twins.md new file mode 100644 index 00000000..e5bd38c6 --- /dev/null +++ b/.changeset/activity-actions-record-label-and-twins.md @@ -0,0 +1,26 @@ +--- +'hotcrm': patch +--- + +Give `log_call` / `log_meeting` a real `record_label`, and stop the two twins +drifting apart. The activity writers stamped `record_label: ctx.record?.name`, +but `name` is not the display field on almost anything in this app — 14 of the +15 objects declare a different `nameField` (`display_title`, `full_name`, +`subject`, `contract_number`, …) and most have no `name` column at all, +`crm_case` — the object both actions are scoped to — included, so every logged +call and meeting landed on the timeline with a null label. The bodies now +resolve the object's declared `nameField` through a map derived from the object +definitions, so it cannot drift and it keeps working if these actions are +restored to the global design. + +The two actions were also near-verbatim copies that had quietly stopped +agreeing on whether `duration` is required (yes for calls, no for meetings, +undocumented either way). Everything they share — body, dispatch declarations, +the subject/duration/notes param core — now comes from one builder, and +`duration` is optional on both, which is what the shared body was already +written for. + +Adds `test/global-actions.test.ts`, which EXECUTES the action bodies rather +than regex-matching them: it asserts the label resolves for every object that +declares a `nameField`, and that the twins emit the same activity row apart +from the summary prefix and the per-kind metadata. Refs #514. diff --git a/src/actions/global.actions.ts b/src/actions/global.actions.ts index 04b413dd..78c71492 100644 --- a/src/actions/global.actions.ts +++ b/src/actions/global.actions.ts @@ -1,152 +1,200 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { Action } from '@objectstack/spec/ui'; +import * as objects from '../objects'; /** - * Log a Call. + * `objectName` → the object's DECLARED `nameField`, derived from the object + * definitions rather than hand-listed. + * + * Issue #514 item 2: the activity writers below stamped + * `record_label: ctx.record?.name`, but `name` is not the display field on + * almost anything here — 14 of the 15 objects declare a different `nameField` + * (`display_title`, `full_name`, `subject`, `contract_number`, …), and most of + * them have no `name` column at all, so the label landed `null`. `crm_case` — + * the object both actions are currently scoped to — is one of those. * - * Modal-typed cross-domain (global) action: collects subject / - * duration / notes then writes an `activity` record via the metadata - * body. The originating record id is forwarded as `related_to_id`. + * Deriving the map has two properties a hardcoded read cannot have: it stays + * correct when an object retargets its `nameField`, and it keeps working if + * these actions are restored to the global design described below (where the + * object is only known at call time). Formula `nameField`s resolve fine — the + * data engine materialises formula fields on read, so the loaded `ctx.record` + * carries `display_title` alongside the stored columns. */ -export const LogCallAction: Action = { - name: 'log_call', - label: 'Log a Call', - // Scoped to crm_case, no longer global — issue #509. The runtime registers - // a body action without an objectName under the key 'global', but the - // dispatcher only probes '' then '*' — a global body action is - // therefore unreachable from every surface ("Action 'log_call' on object - // '*' not found", verified 2026-07-28). crm_case is where the app wires - // this action (case_detail header); scoped, it registers under crm_case - // and executes. When the upstream key mismatch is fixed, restoring the - // global design is just deleting this objectName (and log_meeting's). - objectName: 'crm_case', - icon: 'phone', - // script, not modal: modal submits die on GET /api/v1/meta/object/ - // → 400 in 16.1.0; script actions POST /api/v1/actions/... and execute. - type: 'script', - body: { - language: 'js', - source: ` - const recordId = ctx.recordId ?? ctx.record?.id ?? null; - const objectName = ctx.objectName ?? input.objectName ?? null; - const subject = input.subject ? String(input.subject) : 'Untitled Call'; +const NAME_FIELD_BY_OBJECT: Record = Object.fromEntries( + Object.values(objects as Record) + .filter( + (o): o is { name: string; nameField: string } => + typeof o?.name === 'string' && typeof o?.nameField === 'string', + ) + .map((o) => [o.name, o.nameField]), +); + +/** + * The authored difference between one activity-logging action and the next. + * + * Issue #514 item 15: `log_call` and `log_meeting` were near-verbatim copies — + * identical bodies apart from a summary prefix and a metadata key, identical + * params apart from labels and the meeting's `attendees` — which is how they + * drifted into disagreeing about whether `duration` is required. Everything + * they share now lives in {@link logActivityAction}; this type is the complete + * list of what a twin is still allowed to differ on. + */ +type LogActivitySpec = { + name: string; + label: string; + icon: string; + /** Stamped in front of the activity summary; `''` for calls. */ + summaryPrefix: string; + /** Subject used when the user submits the form with the field blank. */ + defaultSubject: string; + /** `metadata.kind` discriminator on the `sys_activity` row. */ + kind: string; + /** Extra `metadata` entries as `key` → JS expression source, spliced into the body. */ + metadataExtras: Record; + subjectLabel: string; + notesLabel: string; + /** Params appended after the shared subject / duration core. */ + extraParams?: NonNullable; + successMessage: string; +}; + +/** + * Build an activity-logging action from the one shared body + param core. + * + * `duration` is OPTIONAL on every twin. It was `required: true` for calls and + * `required: false` for meetings with nothing documenting the split; optional + * is the direction that keeps both forms submittable, and it is the one the + * body was already written for — the `duration ? … : subject` summary branch + * is unreachable while the field is mandatory. + */ +function logActivityAction(spec: LogActivitySpec): Action { + const extras = Object.entries(spec.metadataExtras) + .map(([key, expr]) => `${key}: ${expr}`) + .join(', '); + return { + name: spec.name, + label: spec.label, + // Scoped to crm_case, no longer global — issue #509. The runtime registers + // a body action without an objectName under the key 'global', but the + // dispatcher only probes '' then '*' — a global body action is + // therefore unreachable from every surface ("Action 'log_call' on object + // '*' not found", verified 2026-07-28). crm_case is where the app wires + // this action (case_detail header); scoped, it registers under crm_case + // and executes. When the upstream key mismatch is fixed, restoring the + // global design is just deleting this objectName. + objectName: 'crm_case', + icon: spec.icon, + // script, not modal: modal submits die on GET /api/v1/meta/object/ + // → 400 in 16.1.0; script actions POST /api/v1/actions/... and execute. + type: 'script', + body: { + language: 'js', + source: ` + const NAME_FIELD_BY_OBJECT = ${JSON.stringify(NAME_FIELD_BY_OBJECT)}; + const record = ctx.record ?? {}; + const recordId = ctx.recordId ?? record.id ?? null; + // \`ctx.object\` is the name the sandbox context carries; the dispatcher + // also mirrors it into the params as \`objectName\`. Both are checked + // because the label lookup below is only as good as this value. + const objectName = ctx.objectName ?? ctx.object ?? input.objectName ?? null; + const subject = input.subject ? String(input.subject) : '${spec.defaultSubject}'; const duration = input.duration ? Number(input.duration) : 0; const notes = input.notes ? String(input.notes) : ''; const summary = duration ? subject + ' (' + duration + ' min)' : subject; + // #514 item 2: read the object's declared nameField, NOT a hardcoded + // \`.name\` — see NAME_FIELD_BY_OBJECT in src/actions/global.actions.ts. + const nameField = NAME_FIELD_BY_OBJECT[objectName] ?? 'name'; const activity = await ctx.api.object('sys_activity').insert({ type: 'completed', - summary, + summary: '${spec.summaryPrefix}' + summary, actor_id: ctx.user?.id ?? null, actor_name: ctx.user?.name ?? null, object_name: objectName, record_id: recordId, - record_label: ctx.record?.name ?? null, - metadata: JSON.stringify({ kind: 'call', duration_minutes: duration, notes, direction: 'outbound' }), + record_label: record[nameField] ?? null, + metadata: JSON.stringify({ kind: '${spec.kind}', duration_minutes: duration, notes, ${extras} }), }); return { activityId: activity?.id }; `, - capabilities: ['api.write'], - timeoutMs: 5000, - }, - locations: ['record_header', 'list_item', 'record_related'], - params: [ - { - name: 'subject', - label: 'Call Subject', - type: 'text', - required: true, - }, - { - name: 'duration', - label: 'Duration (minutes)', - type: 'number', - required: true, + capabilities: ['api.write'], + timeoutMs: 5000, }, - { - name: 'notes', - label: 'Call Notes', - type: 'textarea', - required: false, - } - ], + locations: ['record_header', 'list_item', 'record_related'], + params: [ + { + name: 'subject', + label: spec.subjectLabel, + type: 'text', + required: true, + }, + { + name: 'duration', + label: 'Duration (minutes)', + type: 'number', + required: false, + }, + ...(spec.extraParams ?? []), + { + name: 'notes', + label: spec.notesLabel, + type: 'textarea', + required: false, + }, + ], + successMessage: spec.successMessage, + refreshAfter: true, + }; +} + +/** + * Log a Call. + * + * Collects subject / duration / notes then writes a `sys_activity` record via + * the metadata body. The originating record id is forwarded as `record_id`, + * and the record's display name as `record_label`. + */ +export const LogCallAction: Action = logActivityAction({ + name: 'log_call', + label: 'Log a Call', + icon: 'phone', + summaryPrefix: '', + defaultSubject: 'Untitled Call', + kind: 'call', + metadataExtras: { direction: `'outbound'` }, + subjectLabel: 'Call Subject', + notesLabel: 'Call Notes', successMessage: 'Call logged successfully!', - refreshAfter: true, -}; +}); /** * Log a Meeting. * - * Modal-typed cross-domain (global) action: companion to `log_call`. - * Collects subject / duration / attendees / notes then writes a semantic - * `sys_activity` row so the meeting lands on the record's unified timeline. + * Companion to `log_call`: same `sys_activity` write, plus an `attendees` + * param, so the meeting lands on the record's unified timeline. */ -export const LogMeetingAction: Action = { +export const LogMeetingAction: Action = logActivityAction({ name: 'log_meeting', label: 'Log a Meeting', - // Scoped to crm_case for the same dispatcher-key reason as log_call (issue #509). - objectName: 'crm_case', icon: 'calendar', - type: 'script', - body: { - language: 'js', - source: ` - const recordId = ctx.recordId ?? ctx.record?.id ?? null; - const objectName = ctx.objectName ?? input.objectName ?? null; - const subject = input.subject ? String(input.subject) : 'Untitled Meeting'; - const duration = input.duration ? Number(input.duration) : 0; - const attendees = input.attendees ? String(input.attendees) : ''; - const notes = input.notes ? String(input.notes) : ''; - const summary = duration - ? subject + ' (' + duration + ' min)' - : subject; - const activity = await ctx.api.object('sys_activity').insert({ - type: 'completed', - summary: 'Meeting: ' + summary, - actor_id: ctx.user?.id ?? null, - actor_name: ctx.user?.name ?? null, - object_name: objectName, - record_id: recordId, - record_label: ctx.record?.name ?? null, - metadata: JSON.stringify({ kind: 'meeting', duration_minutes: duration, attendees, notes }), - }); - return { activityId: activity?.id }; - `, - capabilities: ['api.write'], - timeoutMs: 5000, - }, - locations: ['record_header', 'list_item', 'record_related'], - params: [ - { - name: 'subject', - label: 'Meeting Subject', - type: 'text', - required: true, - }, - { - name: 'duration', - label: 'Duration (minutes)', - type: 'number', - required: false, - }, + summaryPrefix: 'Meeting: ', + defaultSubject: 'Untitled Meeting', + kind: 'meeting', + metadataExtras: { attendees: `input.attendees ? String(input.attendees) : ''` }, + subjectLabel: 'Meeting Subject', + notesLabel: 'Meeting Notes', + extraParams: [ { name: 'attendees', label: 'Attendees', type: 'text', required: false, }, - { - name: 'notes', - label: 'Meeting Notes', - type: 'textarea', - required: false, - } ], successMessage: 'Meeting logged successfully!', - refreshAfter: true, -}; +}); // ExportToCsvAction was removed: as a global body action it registered under // the 'global' key the dispatcher never probes (same defect as log_call above), diff --git a/test/global-actions.test.ts b/test/global-actions.test.ts new file mode 100644 index 00000000..9846bad3 --- /dev/null +++ b/test/global-actions.test.ts @@ -0,0 +1,274 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import stack from '../objectstack.config'; + +/** + * Behavioral guards for the activity-logging actions in + * `src/actions/global.actions.ts` (issue #514, items 2 and 15). + * + * These EXECUTE the action bodies instead of regex-matching them. Both bugs + * pinned here are invisible to `os validate`, to `build`, and to a structural + * assertion over the compiled metadata: the first lived in a field read inside + * the sandboxed body (`ctx.record?.name` on objects that have no `name` + * column), the second in two param lists that had quietly stopped agreeing + * with each other. A source-text assertion would have pinned the fix's + * spelling; running the body pins its behavior. + * + * The harness is deliberately tiny — the bodies' only host surface is + * `ctx.api.object(...).insert(...)`, so a recording stub covers it. This is + * NOT a sandbox-accurate harness (the real runtime executes the source under + * QuickJS); it is a plain `new Function` over the same source string, which is + * enough to prove what the body computes. + */ + +type AnyRec = Record; + +const stackActions: AnyRec[] = (stack as any).actions ?? []; +const stackObjects: AnyRec[] = (stack as any).objects ?? []; + +/** An action by name, or a thrown error naming the miss. */ +const action = (name: string): AnyRec => { + const found = stackActions.find((a) => a.name === name); + if (!found) throw new Error(`no ${name} action registered`); + return found; +}; + +/** The two twins this file is about, always exercised as a pair. */ +const TWINS = ['log_call', 'log_meeting'] as const; + +type RunOpts = { + /** Modal params the user submitted. */ + input?: AnyRec; + /** The record the action was invoked on, as the dispatcher loaded it. */ + record?: AnyRec; + /** The object the action was dispatched against. */ + objectName?: string; +}; + +type RunResult = { + result: any; + /** Every `ctx.api.object(o).insert(doc)` the body performed, in order. */ + inserted: { object: string; doc: AnyRec }[]; +}; + +/** + * Execute an action's metadata body against a recording `ctx`. + * + * Mirrors the runtime's action sandbox context (`buildActionSandboxContext`): + * the body sees `input` (the submitted params, into which the dispatcher + * merges `recordId` / `objectName`) and `ctx` (`recordId`, `record`, `object`, + * `user`, `api`). + */ +async function runActionBody(a: AnyRec, opts: RunOpts = {}): Promise { + const source: string = a.body?.source ?? ''; + if (!source) throw new Error(`action ${a.name} has no body source to run`); + + const inserted: RunResult['inserted'] = []; + const recordId = opts.record?.id ?? null; + const ctx = { + recordId, + record: opts.record, + object: opts.objectName, + user: { id: 'usr_1', name: 'Ada Lovelace' }, + api: { + object: (object: string) => ({ + insert: async (doc: AnyRec) => { + inserted.push({ object, doc }); + return { id: `${object}_1` }; + }, + }), + }, + }; + const input = { ...(opts.input ?? {}), recordId, objectName: opts.objectName }; + + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const run = new Function('input', 'ctx', `return (async () => {${source}})();`) as ( + i: AnyRec, + c: AnyRec, + ) => Promise; + const result = await run(input, ctx); + return { result, inserted }; +} + +/** The single `sys_activity` row an activity action is expected to write. */ +async function loggedActivity(actionName: string, opts: RunOpts = {}): Promise { + const { inserted } = await runActionBody(action(actionName), opts); + const activities = inserted.filter((i) => i.object === 'sys_activity'); + expect(activities, `${actionName} wrote ${activities.length} sys_activity rows, expected 1`).toHaveLength(1); + return activities[0]!.doc; +} + +/** Objects that declare a `nameField`, i.e. that have a resolvable display name. */ +const objectsWithNameField = stackObjects.filter((o) => typeof o.nameField === 'string'); + +describe('activity actions stamp a real record_label (#514 item 2)', () => { + it('the codebase still makes this worth guarding — most nameFields are not `name`', () => { + // If this ever drops to zero the guards below become vacuous: reading + // `record.name` would be right everywhere and the bug could not recur. + const notName = objectsWithNameField.filter((o) => o.nameField !== 'name'); + expect(objectsWithNameField.length, 'no object declares a nameField').toBeGreaterThan(0); + expect( + notName.map((o) => o.name), + 'expected most objects to declare a nameField other than `name`', + ).not.toEqual([]); + expect(notName.length).toBeGreaterThan(objectsWithNameField.length / 2); + }); + + it.each(TWINS)('%s resolves the declared nameField on every object', async (name) => { + const bad: string[] = []; + for (const object of objectsWithNameField) { + const nameField: string = object.nameField; + const expected = `label of ${object.name}`; + const doc = await loggedActivity(name, { + objectName: object.name, + // A record as the dispatcher loads it: stored columns plus the + // materialised formula fields, of which `nameField` may be one. + record: { id: 'rec_1', [nameField]: expected }, + input: { subject: 'Quarterly sync' }, + }); + if (doc.record_label !== expected) { + bad.push(`${object.name}: nameField=${nameField} → record_label=${JSON.stringify(doc.record_label)}`); + } + } + expect(bad, `${name} failed to resolve the display name:\n ${bad.join('\n ')}`).toEqual([]); + }); + + it.each(TWINS)('%s labels a crm_case by display_title, the field it is scoped to', async (name) => { + // Both actions declare objectName: 'crm_case', whose nameField is the + // `display_title` formula — there is no `name` column on the object at + // all, so the old hardcoded read produced null on every single dispatch. + const doc = await loggedActivity(name, { + objectName: 'crm_case', + record: { id: 'case_1', display_title: 'CASE-00042 - Printer offline', subject: 'Printer offline' }, + input: { subject: 'Follow-up with customer' }, + }); + expect(doc.record_label).toBe('CASE-00042 - Printer offline'); + expect(doc.object_name).toBe('crm_case'); + expect(doc.record_id).toBe('case_1'); + }); + + it.each(TWINS)('%s writes null rather than throwing when the label field is absent', async (name) => { + const doc = await loggedActivity(name, { + objectName: 'crm_case', + record: { id: 'case_1' }, + input: { subject: 'Follow-up with customer' }, + }); + expect(doc.record_label).toBeNull(); + }); + + it.each(TWINS)('%s survives an unknown object with no declared nameField', async (name) => { + const doc = await loggedActivity(name, { + objectName: 'not_a_registered_object', + record: { id: 'x_1' }, + input: { subject: 'Follow-up' }, + }); + expect(doc.record_label).toBeNull(); + }); +}); + +describe('log_call and log_meeting stay twins (#514 item 15)', () => { + const paramsOf = (name: string): AnyRec[] => action(name).params ?? []; + const param = (actionName: string, paramName: string): AnyRec => { + const p = paramsOf(actionName).find((x) => x.name === paramName); + if (!p) throw new Error(`${actionName} declares no "${paramName}" param`); + return p; + }; + + it('agrees on whether duration is required — and it is not', () => { + // The asymmetry this pins: `duration` was required for calls and optional + // for meetings, undocumented either way. Optional on both is what the + // shared body is written for (its `duration ? … : subject` summary branch + // is dead while the field is mandatory). + for (const name of TWINS) { + expect(param(name, 'duration').required, `${name}.duration requiredness`).toBe(false); + } + }); + + it('agrees on the shared subject/notes core', () => { + expect(param('log_call', 'subject').required).toBe(true); + expect(param('log_meeting', 'subject').required).toBe(true); + expect(param('log_call', 'notes').required).toBe(false); + expect(param('log_meeting', 'notes').required).toBe(false); + for (const [a, b] of [ + ['log_call', 'log_meeting'], + ['log_meeting', 'log_call'], + ] as const) { + expect(param(a, 'subject').type).toBe(param(b, 'subject').type); + expect(param(a, 'notes').type).toBe(param(b, 'notes').type); + expect(param(a, 'duration').type).toBe(param(b, 'duration').type); + } + }); + + it('attendees is the only param the meeting adds', () => { + const callParams = paramsOf('log_call').map((p) => p.name); + const meetingParams = paramsOf('log_meeting').map((p) => p.name); + expect(meetingParams.filter((p) => !callParams.includes(p))).toEqual(['attendees']); + expect(callParams.filter((p) => !meetingParams.includes(p))).toEqual([]); + }); + + it('shares every dispatch-level declaration except icon and labels', () => { + const call = action('log_call'); + const meeting = action('log_meeting'); + for (const key of ['type', 'objectName', 'refreshAfter'] as const) { + expect(meeting[key], `log_meeting.${key}`).toEqual(call[key]); + } + expect(meeting.locations).toEqual(call.locations); + expect(meeting.body?.capabilities).toEqual(call.body?.capabilities); + expect(meeting.body?.timeoutMs).toEqual(call.body?.timeoutMs); + }); + + it('emits the same activity row apart from the summary prefix and metadata', async () => { + const opts: RunOpts = { + objectName: 'crm_case', + record: { id: 'case_1', display_title: 'CASE-00042 - Printer offline' }, + input: { subject: 'Quarterly sync', duration: 30, notes: 'Agreed next steps' }, + }; + const call = await loggedActivity('log_call', opts); + const meeting = await loggedActivity('log_meeting', opts); + + const shape = (doc: AnyRec) => { + const { summary, metadata, ...rest } = doc; + return rest; + }; + expect(shape(meeting)).toEqual(shape(call)); + + expect(call.summary).toBe('Quarterly sync (30 min)'); + expect(meeting.summary).toBe('Meeting: Quarterly sync (30 min)'); + + const callMeta = JSON.parse(call.metadata); + const meetingMeta = JSON.parse(meeting.metadata); + expect(callMeta.kind).toBe('call'); + expect(meetingMeta.kind).toBe('meeting'); + // The shared metadata core must agree; only the per-kind extras differ. + for (const key of ['duration_minutes', 'notes'] as const) { + expect(meetingMeta[key], `metadata.${key}`).toEqual(callMeta[key]); + } + expect(callMeta.direction).toBe('outbound'); + expect(meetingMeta.attendees).toBe(''); + }); + + it.each(TWINS)('%s drops the duration suffix when duration is omitted', async (name) => { + // Reachable only because duration is optional on both — the branch this + // asserts was dead code for log_call while the param was required. + const doc = await loggedActivity(name, { + objectName: 'crm_case', + record: { id: 'case_1', display_title: 'CASE-00042 - Printer offline' }, + input: { subject: 'Quarterly sync' }, + }); + expect(doc.summary).not.toMatch(/min\)/); + expect(doc.summary.endsWith('Quarterly sync')).toBe(true); + expect(JSON.parse(doc.metadata).duration_minutes).toBe(0); + }); + + it.each(TWINS)('%s carries the acting user onto the activity', async (name) => { + const doc = await loggedActivity(name, { + objectName: 'crm_case', + record: { id: 'case_1', display_title: 'CASE-00042 - Printer offline' }, + input: { subject: 'Quarterly sync' }, + }); + expect(doc.actor_id).toBe('usr_1'); + expect(doc.actor_name).toBe('Ada Lovelace'); + expect(doc.type).toBe('completed'); + }); +});