|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// sys_organization's rename door: a name/logo-only action a record page can |
| 4 | +// offer. |
| 5 | +// |
| 6 | +// `update_organization` renames too, but it is declared at `list_item` only (a |
| 7 | +// record page's header never offers it) and it bundles `slug`, which the cloud |
| 8 | +// orchestrator's `change_slug` governs where it exists. So the record page |
| 9 | +// needs its own door for `name` and `logo`, one that sends no slug. |
| 10 | +// |
| 11 | +// What this file pins, from source: |
| 12 | +// - the new action's declaration: its params are exactly `name` and `logo`, |
| 13 | +// it sits at `record_header` and `list_item`, it targets better-auth's |
| 14 | +// `organization/update` exactly as `update_organization` does, and it is |
| 15 | +// gated on `multiOrgEnabled` like every sibling organization action; |
| 16 | +// - one door per field on the record header: no `record_header` action that |
| 17 | +// targets `organization/update` carries a `slug` param; |
| 18 | +// - the header order: the rename takes an inline slot and `change_slug` folds |
| 19 | +// into the overflow (the ordering model is described in |
| 20 | +// `sys-organization-invite-action.test.ts`); |
| 21 | +// - controls: `update_organization` and `change_slug` are unchanged. |
| 22 | +// |
| 23 | +// The runtime half, a rename through better-auth that reads the slug back |
| 24 | +// unchanged, is `organization-rename-door.dogfood.test.ts` in |
| 25 | +// `packages/qa/dogfood`. |
| 26 | +import { describe, expect, it } from 'vitest'; |
| 27 | +import { featureGatePredicate } from '@objectstack/spec/kernel'; |
| 28 | +import { SysOrganization } from './sys-organization.object.js'; |
| 29 | + |
| 30 | +interface DeclaredParam { |
| 31 | + field?: string; |
| 32 | + name?: string; |
| 33 | + required?: boolean; |
| 34 | + defaultFromRow?: boolean; |
| 35 | +} |
| 36 | + |
| 37 | +interface DeclaredAction { |
| 38 | + name?: string; |
| 39 | + type?: string; |
| 40 | + target?: string; |
| 41 | + method?: string; |
| 42 | + recordIdParam?: string; |
| 43 | + bodyShape?: unknown; |
| 44 | + order?: number; |
| 45 | + locations?: string[]; |
| 46 | + visible?: { source?: string }; |
| 47 | + params?: DeclaredParam[]; |
| 48 | +} |
| 49 | + |
| 50 | +const UPDATE_TARGET = '/api/v1/auth/organization/update'; |
| 51 | + |
| 52 | +/** objectui page:header's default desktop inline budget. */ |
| 53 | +const MAX_VISIBLE = 3; |
| 54 | + |
| 55 | +const actions = (): DeclaredAction[] => |
| 56 | + ((SysOrganization as { actions?: DeclaredAction[] }).actions) ?? []; |
| 57 | + |
| 58 | +function action(name: string): DeclaredAction { |
| 59 | + const found = actions().find((a) => a.name === name); |
| 60 | + expect(found, `${name} is declared`).toBeDefined(); |
| 61 | + return found as DeclaredAction; |
| 62 | +} |
| 63 | + |
| 64 | +const paramKeys = (a: DeclaredAction): string[] => (a.params ?? []).map((p) => (p.field ?? p.name) as string); |
| 65 | + |
| 66 | +/** The object's `record_header` actions, in the order the stable `order` sort gives them. */ |
| 67 | +function headerOrder(): string[] { |
| 68 | + return actions() |
| 69 | + .filter((a) => (a.locations ?? []).includes('record_header')) |
| 70 | + .map((a, i) => ({ a, i })) |
| 71 | + .sort((x, y) => (x.a.order ?? 0) - (y.a.order ?? 0) || x.i - y.i) |
| 72 | + .map(({ a }) => a.name as string); |
| 73 | +} |
| 74 | + |
| 75 | +describe('sys_organization.rename_organization: the declaration', () => { |
| 76 | + it('collects exactly `name` (required) and `logo`, both pre-filled from the row, and no `slug`', () => { |
| 77 | + const rename = action('rename_organization'); |
| 78 | + expect(paramKeys(rename)).toEqual(['name', 'logo']); |
| 79 | + const [name, logo] = rename.params ?? []; |
| 80 | + expect(name).toMatchObject({ field: 'name', required: true, defaultFromRow: true }); |
| 81 | + expect(logo).toMatchObject({ field: 'logo', defaultFromRow: true }); |
| 82 | + expect(logo?.required).not.toBe(true); |
| 83 | + }); |
| 84 | + |
| 85 | + it('is offered at the record header and on the list row', () => { |
| 86 | + expect(action('rename_organization').locations).toEqual(['record_header', 'list_item']); |
| 87 | + }); |
| 88 | + |
| 89 | + it("targets better-auth's organization/update exactly as update_organization does", () => { |
| 90 | + const rename = action('rename_organization'); |
| 91 | + const update = action('update_organization'); |
| 92 | + expect(rename.type).toBe('api'); |
| 93 | + expect(rename.target).toBe(UPDATE_TARGET); |
| 94 | + expect(rename.recordIdParam).toBe('organizationId'); |
| 95 | + expect(rename.bodyShape).toEqual({ wrap: 'data' }); |
| 96 | + expect({ type: rename.type, target: rename.target, method: rename.method, recordIdParam: rename.recordIdParam, bodyShape: rename.bodyShape }) |
| 97 | + .toEqual({ type: update.type, target: update.target, method: update.method, recordIdParam: update.recordIdParam, bodyShape: update.bodyShape }); |
| 98 | + }); |
| 99 | + |
| 100 | + it('is gated on multiOrgEnabled, the same lowered predicate as update_organization', () => { |
| 101 | + const gate = featureGatePredicate('multiOrgEnabled'); |
| 102 | + expect(action('rename_organization').visible?.source).toBe(gate); |
| 103 | + expect(action('update_organization').visible?.source).toBe(gate); |
| 104 | + }); |
| 105 | +}); |
| 106 | + |
| 107 | +describe('sys_organization: one door per field on the record header', () => { |
| 108 | + it('no record_header action that writes through organization/update collects a slug', () => { |
| 109 | + const headerWriters = actions() |
| 110 | + .filter((a) => (a.locations ?? []).includes('record_header')) |
| 111 | + .filter((a) => a.target === UPDATE_TARGET); |
| 112 | + expect(headerWriters.map((a) => a.name)).toEqual(['rename_organization']); |
| 113 | + for (const a of headerWriters) { |
| 114 | + expect(paramKeys(a), `${a.name} sends a slug to better-auth`).not.toContain('slug'); |
| 115 | + } |
| 116 | + }); |
| 117 | + |
| 118 | + it('the rename takes an inline header slot and change_slug folds into the overflow', () => { |
| 119 | + const order = headerOrder(); |
| 120 | + const names = actions().map((a) => a.name); |
| 121 | + expect(names.indexOf('rename_organization')).toBeLessThan(names.indexOf('change_slug')); |
| 122 | + expect(order.indexOf('rename_organization')).toBeGreaterThanOrEqual(0); |
| 123 | + expect(order.indexOf('rename_organization')).toBeLessThan(MAX_VISIBLE); |
| 124 | + expect(order.indexOf('change_slug')).toBeGreaterThanOrEqual(MAX_VISIBLE); |
| 125 | + expect(order[order.length - 1]).toBe('leave_organization'); |
| 126 | + }); |
| 127 | +}); |
| 128 | + |
| 129 | +describe('sys_organization: the slug doors are unchanged (controls)', () => { |
| 130 | + it('update_organization keeps name / slug / logo, its target and list_item alone', () => { |
| 131 | + const update = action('update_organization'); |
| 132 | + expect(update.locations).toEqual(['list_item']); |
| 133 | + expect(update.target).toBe(UPDATE_TARGET); |
| 134 | + expect(paramKeys(update)).toEqual(['name', 'slug', 'logo']); |
| 135 | + }); |
| 136 | + |
| 137 | + it("change_slug keeps its cloud orchestrator target, record_header alone and its one slug param", () => { |
| 138 | + const change = action('change_slug'); |
| 139 | + expect(change.locations).toEqual(['record_header']); |
| 140 | + expect(change.target).toBe('/api/v1/cloud/organizations/{id}/change-slug'); |
| 141 | + expect(paramKeys(change)).toEqual(['slug']); |
| 142 | + }); |
| 143 | +}); |
0 commit comments