|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #7781 — `organizations.invitations.list()` hand-wrote its row `status` as |
| 4 | +// `'pending' | 'accepted' | 'rejected' | 'canceled'`, missing `expired` |
| 5 | +// (ObjectStack's own terminal state, driven by `expiresAt`). `listMine()` was |
| 6 | +// worse: a bare `string`. Both are two more hand-copied spellings of the |
| 7 | +// vocabulary `InvitationStatus` (`@objectstack/spec/identity`) already owns — |
| 8 | +// same divergence family as #7726, which had already widened the spec side to |
| 9 | +// five values (adding `canceled`) while this file stayed at four and drifted |
| 10 | +// the other way. |
| 11 | +// |
| 12 | +// The fix types both methods FROM `InvitationStatus` rather than restating it |
| 13 | +// (see `organizations.invitations.{list,listMine}` in `./index.ts`), so a |
| 14 | +// future value added to the spec enum reaches the SDK by construction. This |
| 15 | +// file is the pin that makes a REGRESSION — someone re-literalizing either |
| 16 | +// method, or the spec enum moving without the (already-derived) client |
| 17 | +// following — a compile failure instead of a silent third divergence. |
| 18 | +// |
| 19 | +// Note WHY this is a type-level (`tsc`) pin and not a runtime one: the defect |
| 20 | +// was entirely inside a type annotation over a `JSON.parse` cast. The value |
| 21 | +// arrives off the wire regardless of what the annotation says (see #7781's |
| 22 | +// own "Impact" section) — no input you could feed a running client would ever |
| 23 | +// make a purely-runtime test fail here. The `Eq<...>` comparison below is |
| 24 | +// against `InvitationStatus` ITSELF, never a second hand-written literal that |
| 25 | +// happens to agree today; the runtime `describe` block below is a companion |
| 26 | +// that pins the enum's actual content in prose, for a reader who is not |
| 27 | +// re-deriving the type by eye. |
| 28 | +// |
| 29 | +// Exported at module scope (not inside `it()`): an unread alias in a function |
| 30 | +// body is TS6196 under `noUnusedLocals`, and — the part that matters — a pin |
| 31 | +// no program compiles is a phantom check that stays green after the guarded |
| 32 | +// code is deleted. `pnpm --filter @objectstack/client typecheck` (which runs |
| 33 | +// `tsc --noEmit` over `src/**/*` via `tsconfig.test.json`) is the gate that |
| 34 | +// reads this file; `vitest` does not type-check (#4311) and only runs the |
| 35 | +// `describe` block. |
| 36 | +import { describe, it, expect } from 'vitest'; |
| 37 | +import type { InvitationStatus } from '@objectstack/spec/identity'; |
| 38 | +import { InvitationStatus as InvitationStatusSchema } from '@objectstack/spec/identity'; |
| 39 | +import type { ObjectStackClient } from './index'; |
| 40 | + |
| 41 | +/** Type-level identity helper — same shape as the spec package's pin tests. */ |
| 42 | +type Eq< A, B > = (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; |
| 43 | +type Assert< T extends true > = T; |
| 44 | + |
| 45 | +type ListInvitationsResult = Awaited< ReturnType< ObjectStackClient[ 'organizations' ][ 'invitations' ][ 'list' ] > >; |
| 46 | +type ListMineResult = Awaited< ReturnType< ObjectStackClient[ 'organizations' ][ 'invitations' ][ 'listMine' ] > >; |
| 47 | + |
| 48 | +type ListRowStatus = ListInvitationsResult[ 'invitations' ][ number ][ 'status' ]; |
| 49 | +type ListMineRowStatus = ListMineResult[ 'invitations' ][ number ][ 'status' ]; |
| 50 | + |
| 51 | +/** |
| 52 | + * `list()`'s declared row `status` IS `InvitationStatus` — not a literal union |
| 53 | + * that merely lists the same values today. A re-literalization (even one that |
| 54 | + * currently agrees) or a spec-side change this file's import does not follow |
| 55 | + * makes `Eq` evaluate `false`, and `Assert< false >` fails to compile. |
| 56 | + */ |
| 57 | +export type ListStatusIsTheSpecUnion = Assert< Eq< ListRowStatus, InvitationStatus > >; |
| 58 | + |
| 59 | +/** Same requirement for `listMine()`, which was a bare `string` before #7781. */ |
| 60 | +export type ListMineStatusIsTheSpecUnion = Assert< Eq< ListMineRowStatus, InvitationStatus > >; |
| 61 | + |
| 62 | +describe('#7781 organizations.invitations — status vocabulary parity with the spec enum', () => { |
| 63 | + it('the spec enum currently carries exactly the five shipped values, in this order', () => { |
| 64 | + // Runtime companion to the type-level pin above: this is the CONTENT a |
| 65 | + // non-TypeScript reader can check without reading compiler output. Order |
| 66 | + // matters here only in that it documents what's live, not because either |
| 67 | + // consuming SELECT depends on enum declaration order. |
| 68 | + expect([...InvitationStatusSchema.options]).toEqual([ |
| 69 | + 'pending', |
| 70 | + 'accepted', |
| 71 | + 'rejected', |
| 72 | + 'expired', |
| 73 | + 'canceled', |
| 74 | + ]); |
| 75 | + }); |
| 76 | + |
| 77 | + it('every value the SDK previously hand-listed is a real spec value', () => { |
| 78 | + // Reverse of the card's headline complaint: the pre-fix literal was |
| 79 | + // `'pending' | 'accepted' | 'rejected' | 'canceled'` — checking here that |
| 80 | + // none of those four is dead SDK-only surface the platform never emits. |
| 81 | + for (const value of ['pending', 'accepted', 'rejected', 'canceled'] as const) { |
| 82 | + expect(InvitationStatusSchema.options).toContain(value); |
| 83 | + } |
| 84 | + }); |
| 85 | + |
| 86 | + it('refuses a value outside the vocabulary', () => { |
| 87 | + expect(InvitationStatusSchema.safeParse('cancelled').success).toBe(false); |
| 88 | + }); |
| 89 | +}); |
0 commit comments