|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect } from 'vitest'; |
| 4 | +import { readFileSync, readdirSync } from 'node:fs'; |
| 5 | +import { dirname, join, relative, resolve } from 'node:path'; |
| 6 | +import { fileURLToPath } from 'node:url'; |
| 7 | +import ts from 'typescript'; |
| 8 | +import { SysRecordShare } from './sys-record-share.object.js'; |
| 9 | +import { SysSharingRule } from './sys-sharing-rule.object.js'; |
| 10 | + |
| 11 | +/** |
| 12 | + * [#24701] A record share's Recipient column names the recipient through the |
| 13 | + * console's `recipient-picker` face, by a display declaration alone. |
| 14 | + * |
| 15 | + * The face (objectui#12613, at this repository's `.objectui-sha`) reads the |
| 16 | + * row's `recipient_type` from the field's declared siblings, names a `user` id |
| 17 | + * by an exact-id `sys_user` read, and draws the stored text when the row |
| 18 | + * carries no kind. It reads `object_name` only for the `field` recipient kind. |
| 19 | + * |
| 20 | + * Pinned here: |
| 21 | + * 1. the declaration, read off the object, is the sibling object's widget; |
| 22 | + * 2. `dependsOn` names exactly the siblings the face reads for the kinds this |
| 23 | + * object's select can store; |
| 24 | + * 3. the declaration opens no write path: the column keeps its stored shape, |
| 25 | + * the data API stays read-only, so no form draws the picker as an editor; |
| 26 | + * 4. `grant()` stays the one insert of a share row in this package, so every |
| 27 | + * stored recipient is the `user` id it writes (ADR-0111 D7). |
| 28 | + */ |
| 29 | + |
| 30 | +type FieldDef = Record<string, unknown>; |
| 31 | + |
| 32 | +const shareFields = SysRecordShare.fields as Record<string, FieldDef>; |
| 33 | +const ruleFields = SysSharingRule.fields as Record<string, FieldDef>; |
| 34 | + |
| 35 | +/** The stored values a select field offers. */ |
| 36 | +function selectValues(field: FieldDef): string[] { |
| 37 | + const options = (field.options as unknown[] | undefined) ?? []; |
| 38 | + return options.map((o) => String(typeof o === 'object' && o !== null ? (o as { value: unknown }).value : o)); |
| 39 | +} |
| 40 | + |
| 41 | +/** |
| 42 | + * The siblings the `recipient-picker` face reads for an object whose |
| 43 | + * `recipient_type` offers `kinds`: the kind itself always, and the shared |
| 44 | + * object's name only when the `field` kind can be stored. |
| 45 | + */ |
| 46 | +function siblingsTheFaceReads(kinds: readonly string[]): string[] { |
| 47 | + return ['recipient_type', ...(kinds.includes('field') ? ['object_name'] : [])]; |
| 48 | +} |
| 49 | + |
| 50 | +describe('sys_record_share.recipient_id — drawn through the recipient-picker face (#24701)', () => { |
| 51 | + it('declares the recipient-picker widget, the one sys_sharing_rule.recipient_id declares', () => { |
| 52 | + expect(shareFields.recipient_id.widget).toBe('recipient-picker'); |
| 53 | + expect(shareFields.recipient_id.widget).toBe(ruleFields.recipient_id.widget); |
| 54 | + }); |
| 55 | + |
| 56 | + it('dependsOn names exactly the siblings the face reads for the kinds this object stores', () => { |
| 57 | + const kinds = selectValues(shareFields.recipient_type); |
| 58 | + expect(kinds).not.toContain('field'); |
| 59 | + expect(shareFields.recipient_id.dependsOn).toEqual(siblingsTheFaceReads(kinds)); |
| 60 | + for (const sibling of shareFields.recipient_id.dependsOn as string[]) { |
| 61 | + expect(shareFields[sibling], `dependsOn names '${sibling}', a field this object does not declare`).toBeDefined(); |
| 62 | + } |
| 63 | + }); |
| 64 | + |
| 65 | + it('control: the same rule gives sys_sharing_rule its object_name sibling, because it stores the field kind', () => { |
| 66 | + const kinds = selectValues(ruleFields.recipient_type); |
| 67 | + expect(kinds).toContain('field'); |
| 68 | + expect(ruleFields.recipient_id.dependsOn).toEqual(siblingsTheFaceReads(kinds)); |
| 69 | + }); |
| 70 | + |
| 71 | + it('the declaration opens no write path: the stored column is unchanged and the data API stays read-only', () => { |
| 72 | + const recipient = shareFields.recipient_id; |
| 73 | + expect({ |
| 74 | + type: recipient.type, |
| 75 | + required: recipient.required, |
| 76 | + maxLength: recipient.maxLength, |
| 77 | + reference: recipient.reference, |
| 78 | + referenceVia: recipient.referenceVia, |
| 79 | + }).toEqual({ type: 'text', required: true, maxLength: 100, reference: undefined, referenceVia: undefined }); |
| 80 | + const object = SysRecordShare as unknown as { |
| 81 | + managedBy?: unknown; |
| 82 | + enable?: { apiMethods?: unknown }; |
| 83 | + userActions?: unknown; |
| 84 | + }; |
| 85 | + expect(object.managedBy).toBe('engine-owned'); |
| 86 | + expect(object.enable?.apiMethods).toEqual(['get', 'list']); |
| 87 | + expect(object.userActions).toBeUndefined(); |
| 88 | + }); |
| 89 | +}); |
| 90 | + |
| 91 | +/** |
| 92 | + * Every `<receiver>.insert(<table>, …)` call in this package's non-test |
| 93 | + * sources, with the table it names and the function it sits in. A table given |
| 94 | + * as an identifier is resolved through a same-file string constant; anything |
| 95 | + * else is reported as unresolved, so the census cannot be passed by spelling a |
| 96 | + * table some other way. Scoped to this package's `src/` on purpose: the share |
| 97 | + * rows are this plugin's, and outside it the only measured writer (the CLI's |
| 98 | + * organization-ownership apply) updates `source_id` and inserts nothing. |
| 99 | + */ |
| 100 | +describe('sys_record_share — grant() stays the one insert of a share row (#24701)', () => { |
| 101 | + const HERE = dirname(fileURLToPath(import.meta.url)); |
| 102 | + const SRC = resolve(HERE, '..'); |
| 103 | + |
| 104 | + type InsertSite = { file: string; fn: string; table: string }; |
| 105 | + |
| 106 | + function sourceFiles(dir: string): string[] { |
| 107 | + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { |
| 108 | + const path = join(dir, entry.name); |
| 109 | + if (entry.isDirectory()) return sourceFiles(path); |
| 110 | + return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [path] : []; |
| 111 | + }); |
| 112 | + } |
| 113 | + |
| 114 | + function enclosingFunction(node: ts.Node): string { |
| 115 | + for (let at: ts.Node | undefined = node.parent; at; at = at.parent) { |
| 116 | + if ((ts.isMethodDeclaration(at) || ts.isFunctionDeclaration(at)) && at.name) return at.name.getText(); |
| 117 | + } |
| 118 | + return '(module)'; |
| 119 | + } |
| 120 | + |
| 121 | + function insertSites(): InsertSite[] { |
| 122 | + const sites: InsertSite[] = []; |
| 123 | + for (const path of sourceFiles(SRC)) { |
| 124 | + const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true); |
| 125 | + const constants = new Map<string, string>(); |
| 126 | + const calls: ts.CallExpression[] = []; |
| 127 | + const visit = (node: ts.Node): void => { |
| 128 | + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer |
| 129 | + && ts.isStringLiteralLike(node.initializer)) { |
| 130 | + constants.set(node.name.text, node.initializer.text); |
| 131 | + } |
| 132 | + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) |
| 133 | + && node.expression.name.text === 'insert' && node.arguments.length > 0) { |
| 134 | + calls.push(node); |
| 135 | + } |
| 136 | + ts.forEachChild(node, visit); |
| 137 | + }; |
| 138 | + visit(source); |
| 139 | + for (const call of calls) { |
| 140 | + const arg = call.arguments[0]!; |
| 141 | + const table = ts.isStringLiteralLike(arg) |
| 142 | + ? arg.text |
| 143 | + : (ts.isIdentifier(arg) && constants.get(arg.text)) || `(unresolved: ${arg.getText(source)})`; |
| 144 | + sites.push({ file: relative(SRC, path), fn: enclosingFunction(call), table }); |
| 145 | + } |
| 146 | + } |
| 147 | + return sites; |
| 148 | + } |
| 149 | + |
| 150 | + const sites = insertSites(); |
| 151 | + |
| 152 | + it('control: the census reads this package\'s other inserts, so the case below cannot pass on an empty set', () => { |
| 153 | + expect(sites.map((s) => s.table)).toEqual(expect.arrayContaining(['sys_share_link', 'sys_sharing_rule'])); |
| 154 | + }); |
| 155 | + |
| 156 | + it('every insert names its table in a form the census reads', () => { |
| 157 | + expect(sites.filter((s) => s.table.startsWith('(unresolved')).map((s) => `${s.file}#${s.fn}: ${s.table}`)).toEqual([]); |
| 158 | + }); |
| 159 | + |
| 160 | + it('the only insert of a sys_record_share row is grant() in sharing-service.ts', () => { |
| 161 | + expect(sites.filter((s) => s.table === 'sys_record_share').map((s) => `${s.file}#${s.fn}`)) |
| 162 | + .toEqual(['sharing-service.ts#grant']); |
| 163 | + }); |
| 164 | +}); |
0 commit comments