|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; |
| 4 | +import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol'; |
| 5 | +import { SchemaRegistry } from './registry.js'; |
| 6 | + |
| 7 | +/** |
| 8 | + * [#28665, ADR-0055 §1] Making a detail `controlled_by_parent` with its |
| 9 | + * `master_detail` turned required: the 409 `DESTRUCTIVE_CHANGE` finding counts |
| 10 | + * the stored rows that hold no master and says they become unreadable to |
| 11 | + * everyone, the admin included. Before, it said only that such rows "may fail |
| 12 | + * validation", and the Studio confirm relayed that. |
| 13 | + * |
| 14 | + * Pinned, through the real `saveMetaItem` gate: |
| 15 | + * - N masterless rows: the finding (and the 409's message) names N and the |
| 16 | + * consequence; the count is ONE system-authority `count` on that column; |
| 17 | + * - zero masterless rows, or a count that fails: today's finding, unchanged; |
| 18 | + * - `force: true` still saves, and asks nothing; |
| 19 | + * - every other save issues no count at all. |
| 20 | + * |
| 21 | + * Harness: the shape `protocol-destructive.test.ts` uses (a tenant-authored |
| 22 | + * object in a `SchemaRegistry`, a mock engine), with a `count` that answers |
| 23 | + * from a row list by the `$null` predicate it is handed. |
| 24 | + */ |
| 25 | + |
| 26 | +const TODAY = "Field 'project' is now required but has no default value — existing rows with null values may fail validation."; |
| 27 | + |
| 28 | +const masterField = (required: boolean) => ({ |
| 29 | + name: 'project', type: 'master_detail', reference: 'qow_project', ...(required ? { required: true } : {}), |
| 30 | +}); |
| 31 | + |
| 32 | +/** The detail as published: `private`, its master not required. */ |
| 33 | +const storedTask = (over: Record<string, unknown> = {}) => ({ |
| 34 | + name: 'qow_task', |
| 35 | + label: 'Task', |
| 36 | + _provenance: 'org', |
| 37 | + sharingModel: 'private', |
| 38 | + fields: { |
| 39 | + name: { name: 'name', type: 'text' }, |
| 40 | + project: masterField(false), |
| 41 | + }, |
| 42 | + ...over, |
| 43 | +}); |
| 44 | + |
| 45 | +/** The body Studio's "Make required and save" sends. */ |
| 46 | +const controlledTask = (over: Record<string, unknown> = {}) => ({ |
| 47 | + name: 'qow_task', |
| 48 | + label: 'Task', |
| 49 | + sharingModel: 'controlled_by_parent', |
| 50 | + fields: { |
| 51 | + name: { name: 'name', type: 'text' }, |
| 52 | + project: masterField(true), |
| 53 | + }, |
| 54 | + ...over, |
| 55 | +}); |
| 56 | + |
| 57 | +describe('[#28665] the controlled_by_parent master finding counts the rows with no master', () => { |
| 58 | + let protocol: ObjectStackProtocolImplementation; |
| 59 | + let registry: SchemaRegistry; |
| 60 | + let mockEngine: any; |
| 61 | + let taskRows: Array<Record<string, unknown>>; |
| 62 | + |
| 63 | + function boot(stored: Record<string, unknown> = storedTask()) { |
| 64 | + registry = new SchemaRegistry({ multiTenant: false }); |
| 65 | + registry.registerObject({ |
| 66 | + name: 'qow_project', label: 'Project', _provenance: 'org', sharingModel: 'private', |
| 67 | + fields: { name: { name: 'name', type: 'text' } }, |
| 68 | + } as any, 'pkg'); |
| 69 | + registry.registerObject(stored as any, 'pkg'); |
| 70 | + mockEngine = { |
| 71 | + registry, |
| 72 | + find: vi.fn().mockResolvedValue([]), |
| 73 | + findOne: vi.fn().mockResolvedValue(null), |
| 74 | + insert: vi.fn().mockResolvedValue({ id: 'x' }), |
| 75 | + update: vi.fn().mockResolvedValue({ id: 'x' }), |
| 76 | + delete: vi.fn().mockResolvedValue({ deleted: 1 }), |
| 77 | + aggregate: vi.fn().mockResolvedValue([]), |
| 78 | + // Answers only the has-no-value question on one column, with system |
| 79 | + // authority; anything else is a shape this gate does not send. |
| 80 | + count: vi.fn(async (object: string, query: any) => { |
| 81 | + if (object !== 'qow_task') throw new Error(`count on unexpected object ${object}`); |
| 82 | + if (query?.context?.isSystem !== true) throw new Error('count without system authority'); |
| 83 | + const [[field, predicate]] = Object.entries(query?.where ?? {}) as Array<[string, any]>; |
| 84 | + if (predicate?.$null !== true) throw new Error('count without a $null predicate'); |
| 85 | + return taskRows.filter((r) => r[field] === null || r[field] === undefined).length; |
| 86 | + }), |
| 87 | + }; |
| 88 | + protocol = new ObjectStackProtocolImplementation(mockEngine); |
| 89 | + } |
| 90 | + |
| 91 | + const refusalOf = async (run: () => Promise<unknown>): Promise<any> => { |
| 92 | + try { |
| 93 | + await run(); |
| 94 | + } catch (err) { |
| 95 | + return err; |
| 96 | + } |
| 97 | + throw new Error('expected the save to be refused'); |
| 98 | + }; |
| 99 | + const masterFinding = (err: any) => (err.issues as any[]).find((i) => i.code === 'field_required_no_default' && i.field === 'project'); |
| 100 | + |
| 101 | + beforeEach(() => { |
| 102 | + ObjectStackProtocolImplementation.resetEnvWritableCache(); |
| 103 | + resetEnvWritableMetadataTypes(); |
| 104 | + taskRows = [ |
| 105 | + { id: 't1', name: 'orphan-1', project: null }, |
| 106 | + { id: 't2', name: 'orphan-2', project: null }, |
| 107 | + { id: 't3', name: 'owned', project: 'p1' }, |
| 108 | + ]; |
| 109 | + boot(); |
| 110 | + }); |
| 111 | + |
| 112 | + afterEach(() => { |
| 113 | + ObjectStackProtocolImplementation.resetEnvWritableCache(); |
| 114 | + resetEnvWritableMetadataTypes(); |
| 115 | + }); |
| 116 | + |
| 117 | + it('names how many stored rows have no master and that they become unreadable through every door', async () => { |
| 118 | + for (const mode of [undefined, 'draft'] as const) { |
| 119 | + mockEngine.count.mockClear(); |
| 120 | + const err = await refusalOf(() => protocol.saveMetaItem({ |
| 121 | + type: 'object', name: 'qow_task', item: controlledTask(), ...(mode ? { mode } : {}), |
| 122 | + })); |
| 123 | + expect(err).toMatchObject({ code: 'DESTRUCTIVE_CHANGE', status: 409 }); |
| 124 | + const finding = masterFinding(err); |
| 125 | + expect(finding.message).toContain("2 existing rows have no 'project'"); |
| 126 | + expect(finding.message).toContain('unreadable through every door'); |
| 127 | + // The 409's own text carries it too: the faces that quote only the message. |
| 128 | + expect(err.message).toContain("2 existing rows have no 'project'"); |
| 129 | + expect(mockEngine.count).toHaveBeenCalledTimes(1); |
| 130 | + expect(mockEngine.count).toHaveBeenCalledWith('qow_task', { where: { project: { $null: true } }, context: { isSystem: true } }); |
| 131 | + } |
| 132 | + }); |
| 133 | + |
| 134 | + it('counts too when the detail already was controlled_by_parent and its master becomes required now', async () => { |
| 135 | + boot(storedTask({ sharingModel: 'controlled_by_parent' })); |
| 136 | + taskRows = [{ id: 't1', project: null }]; |
| 137 | + const err = await refusalOf(() => protocol.saveMetaItem({ type: 'object', name: 'qow_task', item: controlledTask() })); |
| 138 | + expect(err).toMatchObject({ code: 'DESTRUCTIVE_CHANGE', status: 409 }); |
| 139 | + expect(masterFinding(err).message).toContain("1 existing row has no 'project'"); |
| 140 | + }); |
| 141 | + |
| 142 | + it('with no masterless row the finding is today\'s, unchanged', async () => { |
| 143 | + taskRows = [{ id: 't3', project: 'p1' }]; |
| 144 | + const err = await refusalOf(() => protocol.saveMetaItem({ type: 'object', name: 'qow_task', item: controlledTask() })); |
| 145 | + expect(err).toMatchObject({ code: 'DESTRUCTIVE_CHANGE', status: 409 }); |
| 146 | + expect(masterFinding(err).message).toBe(TODAY); |
| 147 | + expect(mockEngine.count).toHaveBeenCalledTimes(1); |
| 148 | + }); |
| 149 | + |
| 150 | + it('a count that cannot be answered leaves today\'s finding, and the 409 stands', async () => { |
| 151 | + mockEngine.count.mockRejectedValueOnce(Object.assign(new Error('no such table: qow_task'), { code: 'SQLITE_ERROR' })); |
| 152 | + const err = await refusalOf(() => protocol.saveMetaItem({ type: 'object', name: 'qow_task', item: controlledTask() })); |
| 153 | + expect(err).toMatchObject({ code: 'DESTRUCTIVE_CHANGE', status: 409 }); |
| 154 | + expect(masterFinding(err).message).toBe(TODAY); |
| 155 | + }); |
| 156 | + |
| 157 | + it('force=true still saves, and asks the table nothing', async () => { |
| 158 | + const saved = await protocol.saveMetaItem({ type: 'object', name: 'qow_task', item: controlledTask(), force: true }); |
| 159 | + expect(saved).toMatchObject({ success: true }); |
| 160 | + expect(mockEngine.count).not.toHaveBeenCalled(); |
| 161 | + }); |
| 162 | + |
| 163 | + describe('every other save issues no count', () => { |
| 164 | + it('a master made required on a detail that is not controlled_by_parent: today\'s finding, no count', async () => { |
| 165 | + const err = await refusalOf(() => protocol.saveMetaItem({ |
| 166 | + type: 'object', name: 'qow_task', item: controlledTask({ sharingModel: 'private' }), |
| 167 | + })); |
| 168 | + expect(err).toMatchObject({ code: 'DESTRUCTIVE_CHANGE', status: 409 }); |
| 169 | + expect(masterFinding(err).message).toBe(TODAY); |
| 170 | + expect(mockEngine.count).not.toHaveBeenCalled(); |
| 171 | + }); |
| 172 | + |
| 173 | + it('another field made required on a controlled_by_parent detail: no count', async () => { |
| 174 | + boot(storedTask({ sharingModel: 'controlled_by_parent', fields: { name: { name: 'name', type: 'text' }, project: masterField(true) } })); |
| 175 | + const err = await refusalOf(() => protocol.saveMetaItem({ |
| 176 | + type: 'object', name: 'qow_task', |
| 177 | + item: controlledTask({ fields: { name: { name: 'name', type: 'text', required: true }, project: masterField(true) } }), |
| 178 | + })); |
| 179 | + expect(err).toMatchObject({ code: 'DESTRUCTIVE_CHANGE', status: 409 }); |
| 180 | + expect((err.issues as any[]).map((i) => i.code)).toEqual(['field_required_no_default']); |
| 181 | + expect(mockEngine.count).not.toHaveBeenCalled(); |
| 182 | + }); |
| 183 | + |
| 184 | + it('an ordinary save with no destructive change: no count', async () => { |
| 185 | + const saved = await protocol.saveMetaItem({ type: 'object', name: 'qow_task', item: storedTask({ label: 'Tasks', _provenance: undefined }) }); |
| 186 | + expect(saved).toMatchObject({ success: true }); |
| 187 | + expect(mockEngine.count).not.toHaveBeenCalled(); |
| 188 | + }); |
| 189 | + }); |
| 190 | +}); |
0 commit comments