aabddb592f
* feat(billing): multi-user seat gate: multi_user capability, 20-day grace, owner-only dormancy Multiple people in one company becomes a paid capability (multi_user, the eighth PAID key). Derived at access time from capability_grants, no status column, no enforcement cron: - entitled: active grant (trial/stripe/team/manual/comp), everyone works - grace: newest grant expired < 20 days ago; countdown banner for everyone in companies with > 1 user; invites still allowed - frozen: only role=owner resolves; other memberships go dormant (rows untouched, paying reactivates instantly); invites 403 with paid-plan upsell Enforcement: new resolve_active_company_gated RPC (zero-arg RPC and RLS twin untouched: they also run on self-hosts, where the gate never bites), gated query fallback for service-role/API-key paths, setActiveCompany guard, MCP company-access check, invite route. Middleware routes all-frozen users to a new /paused page; the switcher greys locked companies. Migration 20260901081417 (applied to staging): trial trigger seeds multi_user, backfills for mid-trial companies, active Stripe subs, team agreements, and a grandfather grant (expires now, i.e. grace = deploy + 20 days) for existing unpaid multi-member companies. Daily cron mails owners at grace start and last day. Strings in sv+en; pg-real + unit tests included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP * fix(billing): multi-user seat gate hardening from skeptic review - Stripe cancel now EXPIRES the multi_user stripe grant instead of deleting it: the 20-day grace window hangs on an expired row, so a deleted one froze churned payers' staff instantly with no banner and no mail. Other stripe grants keep the freeze-and-retain delete. - New SECURITY DEFINER company_multi_user_state() RPC (migration 20260901083726, applied to staging) and RPC-first getMultiUserState: capability_grants RLS hides team-scoped rows from non-team users, so user-client reads misread byra-covered companies as frozen (switch refusal, wrong switcher locks). - Byra-kind teams get a standing team-scoped multi_user grant (backfill + teams trigger): byra client companies have no company-scoped trial by design, so a grantless byra team would freeze every consultant and client user. - Comped/manual companies with active PAID-key grants extend to multi_user (a comped company must not read as paying while locking out user two). - /api/v1 gets the same dormancy gate as MCP (frozen non-owner -> 403). - PGRST202 on resolution fails OPEN (pre-migration DB has zero multi_user rows; the gated fallback would have frozen every non-owner mid-deploy). - Grace cron: covers team-scoped lapses (byra agreement ending) and skips the start mail for the hand-mailed grandfather cohort. - Tests updated/added across all touched surfaces; pg tests for the new RPC and byra trigger; trial-suppression pg test extended to 8 keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP * fix(billing): decouple seat-gate env check and fail open on gate read throws CI round 1 on #2099: - isMultiUserEnforced no longer imports has-capability: several route test suites partially mock that module and the vitest mock guard threw from inside the v1 seat gate, turning expected 4xx responses into 500s. multi_user is never a connector capability, so the bypass reduces to the same env reads, now inlined. - getMultiUserState wraps its resolution in a fail-open try/catch: a client without .rpc or a thrown network error must never lock users out. - no-phantom-columns ceiling 391 -> 393 with reasons: the seat gate's .or() scope filter (server-resolved UUIDs) and the Stripe cancel expiry update's timestamp .or(); all columns in both strings are literals. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP * fix(billing): membership-guard the multi-user entitlement RPCs (Superagent P3) company_multi_user_ok and company_multi_user_state are SECURITY DEFINER and were granted to authenticated with a caller-supplied company UUID: any logged-in user could probe an arbitrary company's billing state and grace deadline across tenants. Migration 20260901091752 (applied to staging) requires an auth.uid() membership in the target company when a JWT is present, keeps service-role/definer contexts unrestricted, and clamps the grace window to [0, 20] days. pg tests: stranger gets false/NULL, member reads normally, oversized p_grace_days cannot widen the probe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
463 lines
19 KiB
TypeScript
463 lines
19 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
const { mockCookieSet } = vi.hoisted(() => ({ mockCookieSet: vi.fn() }))
|
|
|
|
vi.mock('next/headers', () => ({
|
|
cookies: vi.fn(async () => ({ set: mockCookieSet })),
|
|
}))
|
|
|
|
// Multi-user seat gate seam. Default: NOT enforced and every membership
|
|
// active, so the pre-existing tests keep documenting the ungated resolution
|
|
// (the self-hosted / dev behavior, and the pre-gate semantics). The gated
|
|
// tests at the bottom flip these; the gate's own logic is covered in
|
|
// lib/entitlements/__tests__/multi-user.test.ts.
|
|
const multiUserSeam = vi.hoisted(() => ({
|
|
enforced: false,
|
|
membershipActive: vi.fn().mockResolvedValue(true),
|
|
}))
|
|
vi.mock('@/lib/entitlements/multi-user', async () => {
|
|
const actual = await vi.importActual<typeof import('@/lib/entitlements/multi-user')>(
|
|
'@/lib/entitlements/multi-user',
|
|
)
|
|
return {
|
|
...actual,
|
|
isMultiUserEnforced: () => multiUserSeam.enforced,
|
|
isMembershipActive: (...args: unknown[]) => multiUserSeam.membershipActive(...args),
|
|
}
|
|
})
|
|
|
|
import { setActiveCompany, CompanyContextError, getCompanyDisplayName, getActiveCompanyId } from '../context'
|
|
|
|
type CapturedCall = { table: string; method: string; args: unknown[] }
|
|
|
|
type TerminalResult = { data?: unknown; error?: unknown }
|
|
|
|
/**
|
|
* Chainable Supabase mock (same approach as actions.test.ts): a chain method
|
|
* terminates with `results[table][method]` when seeded, otherwise keeps
|
|
* chaining. setActiveCompany ends both its queries on `.single()`, on
|
|
* different tables, so seeding `single` per table drives each branch.
|
|
* A terminal seeded as an ARRAY is consumed in call order, for functions
|
|
* that query the same table twice (getActiveCompanyId's fallback fetch +
|
|
* preference validation both end on company_members.maybeSingle()).
|
|
*
|
|
* `rpcResult` seeds supabase.rpc('resolve_active_company'). The default is a
|
|
* PGRST202 "function not found" error so every pre-RPC test keeps passing
|
|
* unchanged: they now exercise the query fallback path, which is exactly the
|
|
* behavior on a not-yet-migrated database.
|
|
*/
|
|
function buildSupabase(
|
|
results: Record<string, Record<string, TerminalResult | TerminalResult[]>>,
|
|
rpcResult: TerminalResult = {
|
|
data: null,
|
|
error: { code: 'PGRST202', message: 'Could not find the function' },
|
|
},
|
|
) {
|
|
const calls: CapturedCall[] = []
|
|
|
|
function makeChain(table: string) {
|
|
const chain: Record<string, unknown> = {}
|
|
const methods = ['select', 'eq', 'is', 'or', 'order', 'limit', 'maybeSingle', 'single', 'insert', 'upsert', 'delete', 'update']
|
|
for (const m of methods) {
|
|
chain[m] = (...args: unknown[]) => {
|
|
calls.push({ table, method: m, args })
|
|
const seeded = results[table]?.[m]
|
|
const terminal = Array.isArray(seeded) ? seeded.shift() : seeded
|
|
if (terminal) {
|
|
return Promise.resolve({ data: terminal.data ?? null, error: terminal.error ?? null })
|
|
}
|
|
return chain
|
|
}
|
|
}
|
|
chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
|
|
return chain
|
|
}
|
|
|
|
const supabase = {
|
|
from: vi.fn().mockImplementation((table: string) => makeChain(table)),
|
|
rpc: vi.fn(async () => ({
|
|
data: rpcResult.data ?? null,
|
|
error: rpcResult.error ?? null,
|
|
})),
|
|
}
|
|
|
|
return { supabase, calls }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
multiUserSeam.enforced = false
|
|
multiUserSeam.membershipActive.mockResolvedValue(true)
|
|
})
|
|
|
|
describe('setActiveCompany', () => {
|
|
it('throws not_member and never writes when the user lacks membership', async () => {
|
|
const { supabase, calls } = buildSupabase({
|
|
company_members: { single: { data: null, error: { message: 'no rows' } } },
|
|
})
|
|
|
|
const err = await setActiveCompany(supabase as never, 'user-1', 'company-2').catch((e) => e)
|
|
|
|
expect(err).toBeInstanceOf(CompanyContextError)
|
|
expect(err.code).toBe('not_member')
|
|
expect(calls.find((c) => c.table === 'user_preferences')).toBeUndefined()
|
|
expect(mockCookieSet).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('throws persist_failed and does NOT set the cookie when the upsert errors (#701)', async () => {
|
|
const { supabase } = buildSupabase({
|
|
company_members: { single: { data: { company_id: 'company-2' } } },
|
|
user_preferences: { single: { data: null, error: { message: 'permission denied' } } },
|
|
})
|
|
|
|
const err = await setActiveCompany(supabase as never, 'user-1', 'company-2').catch((e) => e)
|
|
|
|
expect(err).toBeInstanceOf(CompanyContextError)
|
|
expect(err.code).toBe('persist_failed')
|
|
expect(err.message).toContain('permission denied')
|
|
// The exact regression from #701: cookie must not diverge from the DB.
|
|
expect(mockCookieSet).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('throws persist_failed when the read-back does not return the new company', async () => {
|
|
// An RLS-filtered UPDATE affects zero rows without an error; the
|
|
// read-back is what catches it. Simulate a stale/foreign row coming back.
|
|
const { supabase } = buildSupabase({
|
|
company_members: { single: { data: { company_id: 'company-2' } } },
|
|
user_preferences: { single: { data: { active_company_id: 'company-1' } } },
|
|
})
|
|
|
|
const err = await setActiveCompany(supabase as never, 'user-1', 'company-2').catch((e) => e)
|
|
|
|
expect(err).toBeInstanceOf(CompanyContextError)
|
|
expect(err.code).toBe('persist_failed')
|
|
expect(mockCookieSet).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('sets the cookies only after the write is verified', async () => {
|
|
const { supabase, calls } = buildSupabase({
|
|
company_members: { single: { data: { company_id: 'company-2' } } },
|
|
user_preferences: { single: { data: { active_company_id: 'company-2' } } },
|
|
})
|
|
|
|
await expect(setActiveCompany(supabase as never, 'user-1', 'company-2')).resolves.toBeUndefined()
|
|
|
|
const upsert = calls.find((c) => c.table === 'user_preferences' && c.method === 'upsert')
|
|
expect(upsert?.args[0]).toEqual({ user_id: 'user-1', active_company_id: 'company-2' })
|
|
expect(mockCookieSet).toHaveBeenCalledTimes(2)
|
|
expect(mockCookieSet).toHaveBeenCalledWith(
|
|
'gnubok-company-id',
|
|
'company-2',
|
|
expect.objectContaining({ httpOnly: true, path: '/' }),
|
|
)
|
|
// The explicit-choice marker (byrå landing): must be a SESSION cookie
|
|
// (no maxAge), so a new browser session starts unpicked and byrå
|
|
// owners/admins land in the cockpit again.
|
|
const picked = mockCookieSet.mock.calls.find((c) => c[0] === 'gnubok-company-picked')
|
|
expect(picked?.[1]).toBe('1')
|
|
expect(picked?.[2]).toEqual(
|
|
expect.objectContaining({ httpOnly: true, path: '/', sameSite: 'lax' }),
|
|
)
|
|
expect(picked?.[2]).not.toHaveProperty('maxAge')
|
|
})
|
|
})
|
|
|
|
describe('getActiveCompanyId', () => {
|
|
it('resolves the preferred company with ONE company_members query when it is the first membership', async () => {
|
|
const { supabase, calls } = buildSupabase({
|
|
user_preferences: { maybeSingle: { data: { active_company_id: 'company-1' } } },
|
|
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
|
|
})
|
|
|
|
const id = await getActiveCompanyId(supabase as never, 'user-1')
|
|
|
|
expect(id).toBe('company-1')
|
|
// The parallel fallback fetch doubles as validation in the common
|
|
// single-company case: no second, sequential round trip.
|
|
const memberQueries = calls.filter((c) => c.table === 'company_members' && c.method === 'maybeSingle')
|
|
expect(memberQueries).toHaveLength(1)
|
|
})
|
|
|
|
it('validates a preference that differs from the first membership', async () => {
|
|
const { supabase, calls } = buildSupabase({
|
|
user_preferences: { maybeSingle: { data: { active_company_id: 'company-2' } } },
|
|
company_members: {
|
|
maybeSingle: [
|
|
{ data: { company_id: 'company-1' } }, // first membership (parallel fetch)
|
|
{ data: { company_id: 'company-2' } }, // validation of the preference
|
|
],
|
|
},
|
|
})
|
|
|
|
const id = await getActiveCompanyId(supabase as never, 'user-1')
|
|
|
|
expect(id).toBe('company-2')
|
|
const memberQueries = calls.filter((c) => c.table === 'company_members' && c.method === 'maybeSingle')
|
|
expect(memberQueries).toHaveLength(2)
|
|
})
|
|
|
|
it('falls back to the first membership when the preference is stale', async () => {
|
|
const { supabase } = buildSupabase({
|
|
user_preferences: { maybeSingle: { data: { active_company_id: 'company-archived' } } },
|
|
company_members: {
|
|
maybeSingle: [
|
|
{ data: { company_id: 'company-1' } }, // first membership
|
|
{ data: null }, // validation: preference archived / membership gone
|
|
],
|
|
},
|
|
})
|
|
|
|
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
|
|
})
|
|
|
|
it('falls back to the first membership when there is no preference row', async () => {
|
|
const { supabase, calls } = buildSupabase({
|
|
user_preferences: { maybeSingle: { data: null } },
|
|
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
|
|
})
|
|
|
|
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
|
|
const memberQueries = calls.filter((c) => c.table === 'company_members' && c.method === 'maybeSingle')
|
|
expect(memberQueries).toHaveLength(1)
|
|
})
|
|
|
|
it('returns null when the user has no non-archived memberships', async () => {
|
|
const { supabase } = buildSupabase({
|
|
user_preferences: { maybeSingle: { data: null } },
|
|
company_members: { maybeSingle: { data: null } },
|
|
})
|
|
|
|
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBeNull()
|
|
})
|
|
|
|
// A failed query must throw, never read as "no companies": callers redirect
|
|
// the null state to the onboarding wizard, and a transient failure was
|
|
// enough to show onboarding to a fully onboarded user (issue #1053).
|
|
it('throws resolution_failed when the preferences query fails', async () => {
|
|
const { supabase } = buildSupabase({
|
|
user_preferences: { maybeSingle: { data: null, error: { message: 'fetch failed' } } },
|
|
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
|
|
})
|
|
|
|
const err = await getActiveCompanyId(supabase as never, 'user-1').catch((e) => e)
|
|
|
|
expect(err).toBeInstanceOf(CompanyContextError)
|
|
expect(err.code).toBe('resolution_failed')
|
|
})
|
|
|
|
it('throws resolution_failed when the membership query fails', async () => {
|
|
const { supabase } = buildSupabase({
|
|
user_preferences: { maybeSingle: { data: null } },
|
|
company_members: { maybeSingle: { data: null, error: { message: 'timeout' } } },
|
|
})
|
|
|
|
const err = await getActiveCompanyId(supabase as never, 'user-1').catch((e) => e)
|
|
|
|
expect(err).toBeInstanceOf(CompanyContextError)
|
|
expect(err.code).toBe('resolution_failed')
|
|
})
|
|
|
|
it('throws instead of silently switching company when preference validation fails', async () => {
|
|
const { supabase } = buildSupabase({
|
|
user_preferences: { maybeSingle: { data: { active_company_id: 'company-2' } } },
|
|
company_members: {
|
|
maybeSingle: [
|
|
{ data: { company_id: 'company-1' } }, // first membership (parallel fetch)
|
|
{ data: null, error: { message: 'connection reset' } }, // validation FAILS
|
|
],
|
|
},
|
|
})
|
|
|
|
const err = await getActiveCompanyId(supabase as never, 'user-1').catch((e) => e)
|
|
|
|
// Falling back to company-1 here would silently flip a consultant onto
|
|
// the wrong company's books.
|
|
expect(err).toBeInstanceOf(CompanyContextError)
|
|
expect(err.code).toBe('resolution_failed')
|
|
})
|
|
})
|
|
|
|
describe('getActiveCompanyId via resolve_active_company RPC', () => {
|
|
it('resolves from the RPC in one call without touching any table', async () => {
|
|
const { supabase } = buildSupabase(
|
|
{},
|
|
{ data: [{ company_id: 'company-1', locale: 'sv', used_fallback: false }] },
|
|
)
|
|
|
|
const id = await getActiveCompanyId(supabase as never, 'user-1')
|
|
|
|
expect(id).toBe('company-1')
|
|
expect(supabase.rpc).toHaveBeenCalledWith('resolve_active_company')
|
|
// The whole point of the RPC: zero PostgREST table round trips.
|
|
expect(supabase.from).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns null from an RPC row with a null company_id (no companies) without table queries', async () => {
|
|
const { supabase } = buildSupabase(
|
|
{},
|
|
{ data: [{ company_id: null, locale: 'en', used_fallback: true }] },
|
|
)
|
|
|
|
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBeNull()
|
|
expect(supabase.from).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('throws resolution_failed on a non-fallback RPC error instead of masking it', async () => {
|
|
const { supabase } = buildSupabase(
|
|
{},
|
|
{ data: null, error: { code: '57014', message: 'statement timeout' } },
|
|
)
|
|
|
|
const err = await getActiveCompanyId(supabase as never, 'user-1').catch((e) => e)
|
|
|
|
expect(err).toBeInstanceOf(CompanyContextError)
|
|
expect(err.code).toBe('resolution_failed')
|
|
expect(supabase.from).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('falls back to the query path on 42501 (service-role client lacks EXECUTE)', async () => {
|
|
// The mcp-oauth token route and the events route (API-key branch) call
|
|
// requireCompanyId with createServiceClientNoCookies(): EXECUTE is
|
|
// granted to `authenticated` only, so the RPC refuses with 42501 and the
|
|
// query path (filtered by the explicit userId param) must take over.
|
|
const { supabase } = buildSupabase(
|
|
{
|
|
user_preferences: { maybeSingle: { data: { active_company_id: 'company-1' } } },
|
|
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
|
|
},
|
|
{ data: null, error: { code: '42501', message: 'permission denied for function' } },
|
|
)
|
|
|
|
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
|
|
})
|
|
|
|
it('falls back to the query path on zero RPC rows (NULL auth.uid(), service client)', async () => {
|
|
const { supabase } = buildSupabase(
|
|
{
|
|
user_preferences: { maybeSingle: { data: null } },
|
|
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
|
|
},
|
|
{ data: [] },
|
|
)
|
|
|
|
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
|
|
})
|
|
})
|
|
|
|
describe('multi-user seat gate', () => {
|
|
it('setActiveCompany refuses a switch into a company frozen for the member', async () => {
|
|
multiUserSeam.membershipActive.mockResolvedValue(false)
|
|
const { supabase, calls } = buildSupabase({
|
|
company_members: { single: { data: { company_id: 'company-2', role: 'member' } } },
|
|
})
|
|
|
|
const err = await setActiveCompany(supabase as never, 'user-1', 'company-2').catch((e) => e)
|
|
|
|
expect(err).toBeInstanceOf(CompanyContextError)
|
|
expect(err.code).toBe('company_locked')
|
|
// The preference write must never land: a persisted frozen preference
|
|
// would silently bounce every later resolution.
|
|
expect(calls.find((c) => c.table === 'user_preferences')).toBeUndefined()
|
|
expect(mockCookieSet).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('getActiveCompanyId calls the GATED rpc when enforcement is on', async () => {
|
|
multiUserSeam.enforced = true
|
|
const { supabase } = buildSupabase(
|
|
{},
|
|
{ data: [{ company_id: 'company-1', locale: 'sv', used_fallback: false }] },
|
|
)
|
|
|
|
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
|
|
expect(supabase.rpc).toHaveBeenCalledWith('resolve_active_company_gated', {
|
|
p_grace_days: 20,
|
|
})
|
|
})
|
|
|
|
it('gated query fallback resolves the first ACCESSIBLE membership (dormant skipped)', async () => {
|
|
multiUserSeam.enforced = true
|
|
const memberships = [
|
|
// Frozen for the user: non-owner and no grant rows will match below.
|
|
{ company_id: 'frozen-co', role: 'member', created_at: '2026-01-01', companies: { team_id: null } },
|
|
{ company_id: 'owned-co', role: 'owner', created_at: '2026-02-01', companies: { team_id: null } },
|
|
]
|
|
const { supabase } = buildSupabase(
|
|
{
|
|
user_preferences: { maybeSingle: { data: { active_company_id: 'frozen-co' } } },
|
|
// The gated path awaits the list query (no maybeSingle): seed the
|
|
// chain's `order` terminal.
|
|
company_members: { order: { data: memberships } },
|
|
// No multi_user grants at all -> frozen-co is frozen for the member
|
|
// (the shared rpc mock returns zero rows for company_multi_user_state
|
|
// too, so getMultiUserState falls through to this grants read).
|
|
capability_grants: { or: { data: [] } },
|
|
},
|
|
// Zero RPC rows = NULL auth.uid() (service-role client): routes to the
|
|
// GATED query path, unlike PGRST202 (migration absent), which fails open.
|
|
{ data: [] },
|
|
)
|
|
|
|
// Preference points at the frozen company: resolution must skip it and
|
|
// land on the owned company instead of locking the user out.
|
|
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('owned-co')
|
|
})
|
|
|
|
it('PGRST202 on the gated rpc fails OPEN via the ungated query path (deploy race)', async () => {
|
|
multiUserSeam.enforced = true
|
|
// Default rpcResult is PGRST202. Pre-migration there are no multi_user
|
|
// rows, so the gated path would freeze this non-owner: the fallback must
|
|
// be the UNGATED path and still resolve their membership.
|
|
const { supabase } = buildSupabase({
|
|
user_preferences: { maybeSingle: { data: { active_company_id: 'company-1' } } },
|
|
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
|
|
})
|
|
|
|
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
|
|
})
|
|
})
|
|
|
|
describe('getCompanyDisplayName', () => {
|
|
it('returns company_settings.company_name and never reads companies when set', async () => {
|
|
const { supabase, calls } = buildSupabase({
|
|
company_settings: { maybeSingle: { data: { company_name: 'Ny Firma AB' } } },
|
|
companies: { maybeSingle: { data: { name: 'Aktiebolaget Grundstenen 000000' } } },
|
|
})
|
|
|
|
const name = await getCompanyDisplayName(supabase as never, 'company-1')
|
|
|
|
expect(name).toBe('Ny Firma AB')
|
|
// companies.name is the frozen onboarding value: it must not be consulted
|
|
// when the user has set a current name in settings.
|
|
expect(calls.find((c) => c.table === 'companies')).toBeUndefined()
|
|
})
|
|
|
|
it('falls back to companies.name when company_settings has no row', async () => {
|
|
const { supabase } = buildSupabase({
|
|
company_settings: { maybeSingle: { data: null } },
|
|
companies: { maybeSingle: { data: { name: 'Aktiebolaget Grundstenen 000000' } } },
|
|
})
|
|
|
|
expect(await getCompanyDisplayName(supabase as never, 'company-1')).toBe(
|
|
'Aktiebolaget Grundstenen 000000',
|
|
)
|
|
})
|
|
|
|
it('falls back to companies.name when company_settings.company_name is empty', async () => {
|
|
const { supabase } = buildSupabase({
|
|
company_settings: { maybeSingle: { data: { company_name: '' } } },
|
|
companies: { maybeSingle: { data: { name: 'Bolaget AB' } } },
|
|
})
|
|
|
|
expect(await getCompanyDisplayName(supabase as never, 'company-1')).toBe('Bolaget AB')
|
|
})
|
|
|
|
it('returns null when neither table resolves a name', async () => {
|
|
const { supabase } = buildSupabase({
|
|
company_settings: { maybeSingle: { data: null } },
|
|
companies: { maybeSingle: { data: null } },
|
|
})
|
|
|
|
expect(await getCompanyDisplayName(supabase as never, 'company-1')).toBeNull()
|
|
})
|
|
})
|