|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #11544 — the email-invite entry was UNREACHABLE from where admins actually |
| 4 | +// look. The org record page (ADR-0081) opens on tab-0 **Members** |
| 5 | +// (`sys_member`), whose toolbar carried exactly one action — `add_member`, |
| 6 | +// which attaches an ALREADY-REGISTERED user by id. `invite_user` lived only on |
| 7 | +// tab-1 Invitations. The maintainer, looking to "invite a teammate by email", |
| 8 | +// landed on Members and concluded the product had no invite entry at all. |
| 9 | +// |
| 10 | +// Two halves are pinned here, because the fix has two failure modes and they |
| 11 | +// fail in opposite directions: |
| 12 | +// |
| 13 | +// 1. **Reachability + mirror parity.** `invite_user` is now declared on three |
| 14 | +// objects (sys_user, sys_invitation, sys_member). Three copies of one |
| 15 | +// endpoint is exactly the shape that drifts, so the copies are compared to |
| 16 | +// EACH OTHER rather than to hand-copied literals. |
| 17 | +// 2. **Param resolvability — the trap this card walked into.** A field-backed |
| 18 | +// action param inherits its type / options / label from a field on the |
| 19 | +// action's parent object, or on `objectOverride` when it names another. |
| 20 | +// `sys_member` has no `email` field, so the sys_invitation copy could NOT |
| 21 | +// be mirrored verbatim: objectui's `resolveActionParam` answers an |
| 22 | +// unresolvable field-backed param with a `type: 'text'` fallback labelled |
| 23 | +// by the raw field name. Nothing throws, nothing goes red, and the dialog |
| 24 | +// still submits — the ADR-0078 valid-but-inert class. So the pin is |
| 25 | +// generic: EVERY field-backed param of EVERY mirror must name a field that |
| 26 | +// really exists on the object it resolves against. |
| 27 | +import { describe, expect, it } from 'vitest'; |
| 28 | +import { SysInvitation } from './sys-invitation.object.js'; |
| 29 | +import { SysMember } from './sys-member.object.js'; |
| 30 | +import { SysUser } from './sys-user.object.js'; |
| 31 | + |
| 32 | +const INVITE_ENDPOINT = '/api/v1/auth/organization/invite-member'; |
| 33 | + |
| 34 | +/** The objects a param's `objectOverride` may resolve against, by name. */ |
| 35 | +const OBJECTS_BY_NAME: Record<string, unknown> = { |
| 36 | + sys_user: SysUser, |
| 37 | + sys_member: SysMember, |
| 38 | + sys_invitation: SysInvitation, |
| 39 | +}; |
| 40 | + |
| 41 | +interface AnyAction { |
| 42 | + name?: string; |
| 43 | + label?: string; |
| 44 | + icon?: string; |
| 45 | + variant?: string; |
| 46 | + type?: string; |
| 47 | + target?: string; |
| 48 | + locations?: string[]; |
| 49 | + visible?: { source?: string }; |
| 50 | + params?: Array<{ name?: string; field?: string; objectOverride?: string; required?: boolean }>; |
| 51 | +} |
| 52 | + |
| 53 | +/** Named action on an object, asserted present. */ |
| 54 | +function action(object: unknown, name: string): AnyAction { |
| 55 | + const found = (((object as { actions?: AnyAction[] }).actions) ?? []).find((a) => a.name === name); |
| 56 | + expect(found, `${name} is declared`).toBeDefined(); |
| 57 | + return found as AnyAction; |
| 58 | +} |
| 59 | + |
| 60 | +/** Declared `list_toolbar` actions, in declaration order. */ |
| 61 | +function toolbarActions(object: unknown): AnyAction[] { |
| 62 | + return (((object as { actions?: AnyAction[] }).actions) ?? []) |
| 63 | + .filter((a) => (a.locations ?? []).includes('list_toolbar')); |
| 64 | +} |
| 65 | + |
| 66 | +/** The three declaration sites of `invite_user`, as [label, object] rows. */ |
| 67 | +const MIRRORS: Array<[string, unknown]> = [ |
| 68 | + ['sys_user', SysUser], |
| 69 | + ['sys_invitation', SysInvitation], |
| 70 | + ['sys_member', SysMember], |
| 71 | +]; |
| 72 | + |
| 73 | +describe('invite_user — reachable from the default Members tab (#11544)', () => { |
| 74 | + it('is declared on sys_member, in the toolbar the Members tab renders', () => { |
| 75 | + // The regression itself: the whole defect was this action's ABSENCE from |
| 76 | + // this one object. `list_toolbar` is what the org record page's |
| 77 | + // `record:related_list` over sys_member surfaces as header buttons |
| 78 | + // (objectui `RelatedRecordActionsBridge.deriveActions` → `RelatedList`). |
| 79 | + const invite = action(SysMember, 'invite_user'); |
| 80 | + expect(invite.locations).toContain('list_toolbar'); |
| 81 | + expect(invite.type).toBe('api'); |
| 82 | + expect(invite.target).toBe(INVITE_ENDPOINT); |
| 83 | + }); |
| 84 | + |
| 85 | + it('renders before add_member — declaration order IS render order', () => { |
| 86 | + // The bridge filters the child object's actions in array order and the |
| 87 | + // related list maps them in that order, so "which button is leftmost" is |
| 88 | + // decided here and nowhere else. A later reader appending the mirror to |
| 89 | + // the end of the array would restore the defect's visual half while every |
| 90 | + // other assertion in this file stayed green. |
| 91 | + const names = toolbarActions(SysMember).map((a) => a.name); |
| 92 | + expect(names).toContain('invite_user'); |
| 93 | + expect(names).toContain('add_member'); |
| 94 | + expect(names.indexOf('invite_user')).toBeLessThan(names.indexOf('add_member')); |
| 95 | + }); |
| 96 | + |
| 97 | + it('is the ONE primary button on the Members toolbar', () => { |
| 98 | + // The other half of the defect: two `variant: 'primary'` + `user-plus` |
| 99 | + // buttons side by side read as one affordance duplicated, not as two |
| 100 | + // different flows. objectui's `RelatedToolbarButton` draws `primary` as a |
| 101 | + // FILLED button and every other variant as an `outline` one, so this |
| 102 | + // assertion is about pixels an admin really sees, not about a key nobody |
| 103 | + // reads. |
| 104 | + const primaries = toolbarActions(SysMember).filter((a) => a.variant === 'primary'); |
| 105 | + expect(primaries.map((a) => a.name)).toEqual(['invite_user']); |
| 106 | + }); |
| 107 | + |
| 108 | + it('does not share its icon with add_member', () => { |
| 109 | + // Icon and variant are pinned separately on purpose: either one alone |
| 110 | + // still leaves two buttons a glance cannot tell apart. |
| 111 | + const invite = action(SysMember, 'invite_user'); |
| 112 | + const add = action(SysMember, 'add_member'); |
| 113 | + expect(invite.icon).toBe('user-plus'); |
| 114 | + expect(add.icon).not.toBe(invite.icon); |
| 115 | + expect(add.variant).not.toBe('primary'); |
| 116 | + }); |
| 117 | + |
| 118 | + it('leaves add_member itself intact — still the attach-an-existing-user flow', () => { |
| 119 | + // Differentiating the chrome must not have touched the behaviour. The two |
| 120 | + // buttons are only worth distinguishing because they really do different |
| 121 | + // things: one mails an invitation, one binds an existing account. |
| 122 | + const add = action(SysMember, 'add_member'); |
| 123 | + expect(add.target).toBe('/api/v1/auth/organization/add-member'); |
| 124 | + expect((add.params ?? []).map((p) => p.name ?? p.field)).toContain('userId'); |
| 125 | + }); |
| 126 | +}); |
| 127 | + |
| 128 | +describe('invite_user — the three mirrors agree (#11544)', () => { |
| 129 | + it.each(MIRRORS)('%s dispatches the same endpoint from the same location', (_name, object) => { |
| 130 | + const invite = action(object, 'invite_user'); |
| 131 | + expect(invite.type).toBe('api'); |
| 132 | + expect(invite.target).toBe(INVITE_ENDPOINT); |
| 133 | + expect(invite.locations).toContain('list_toolbar'); |
| 134 | + }); |
| 135 | + |
| 136 | + it.each(MIRRORS)('%s carries the same lowered `organization` capability gate', (_name, object) => { |
| 137 | + // `requiresFeature: 'organization'` is authoring sugar — it is lowered to a |
| 138 | + // CEL predicate at ObjectSchema.create time and the sugar key does not |
| 139 | + // survive (pinned in platform-objects.test.ts), so the gate is read from |
| 140 | + // its lowered form. A mirror that lost the gate would render a button that |
| 141 | + // 404s wherever the org capability is off. |
| 142 | + expect(action(object, 'invite_user').visible?.source).toBe('features.organization != false'); |
| 143 | + }); |
| 144 | + |
| 145 | + it.each(MIRRORS)('%s asks for the same two inputs, email and role', (_name, object) => { |
| 146 | + const keys = (action(object, 'invite_user').params ?? []).map((p) => p.name ?? p.field); |
| 147 | + expect(keys).toEqual(['email', 'role']); |
| 148 | + }); |
| 149 | + |
| 150 | + it.each(MIRRORS)('%s requires both of them', (_name, object) => { |
| 151 | + // The endpoint has no default for either; an optional param here is a |
| 152 | + // dialog that submits an incomplete body and answers with a server error. |
| 153 | + for (const p of action(object, 'invite_user').params ?? []) { |
| 154 | + expect(p.required, `${String(p.field ?? p.name)} is required`).toBe(true); |
| 155 | + } |
| 156 | + }); |
| 157 | +}); |
| 158 | + |
| 159 | +describe('invite_user — every field-backed param resolves to a real field (#11544)', () => { |
| 160 | + // THE load-bearing pin. Stated over all three mirrors rather than over the |
| 161 | + // one that was wrong, because the defect is a property of the DECLARATION |
| 162 | + // SHAPE, not of sys_member: any future mirror of any action that copies a |
| 163 | + // `{ field }` param onto an object that lacks that field lands here. |
| 164 | + it.each(MIRRORS)('%s', (name, object) => { |
| 165 | + const params = action(object, 'invite_user').params ?? []; |
| 166 | + expect(params.length).toBeGreaterThan(0); |
| 167 | + for (const p of params) { |
| 168 | + if (!p.field) continue; // inline param — nothing to resolve |
| 169 | + const ownerName = p.objectOverride ?? name; |
| 170 | + const owner = OBJECTS_BY_NAME[ownerName]; |
| 171 | + expect(owner, `${ownerName} is a known object`).toBeDefined(); |
| 172 | + const fields = (owner as { fields?: Record<string, unknown> }).fields ?? {}; |
| 173 | + expect( |
| 174 | + Object.prototype.hasOwnProperty.call(fields, p.field), |
| 175 | + `${name}.invite_user param "${p.field}" resolves against ${ownerName}`, |
| 176 | + ).toBe(true); |
| 177 | + } |
| 178 | + }); |
| 179 | + |
| 180 | + it('sys_member reaches sys_invitation for `email`, and its OWN field for `role`', () => { |
| 181 | + // Spelled out as its own case because it is the asymmetry a reader will |
| 182 | + // want to delete: sys_member declares `role` (from the same |
| 183 | + // BUILTIN_MEMBERSHIP_ROLE_OPTIONS constant sys_invitation reads) but has |
| 184 | + // no `email` column at all, so exactly one of the two params needs the |
| 185 | + // override. Dropping it is silent — see this file's header. |
| 186 | + const [email, role] = action(SysMember, 'invite_user').params ?? []; |
| 187 | + expect(email.field).toBe('email'); |
| 188 | + expect(email.objectOverride).toBe('sys_invitation'); |
| 189 | + expect(role.field).toBe('role'); |
| 190 | + expect(role.objectOverride).toBeUndefined(); |
| 191 | + expect(Object.prototype.hasOwnProperty.call(SysMember.fields ?? {}, 'email')).toBe(false); |
| 192 | + }); |
| 193 | +}); |
0 commit comments