11/** @vitest -environment node */
22
3+ import { recordAudit } from '@sim/audit'
4+ import * as schema from '@sim/db/schema'
5+ import { resetEnvFlagsMock , setEnvFlags } from '@sim/testing'
36import { generateId } from '@sim/utils/id'
7+ import { eq } from 'drizzle-orm'
48import { drizzle } from 'drizzle-orm/postgres-js'
59import postgres from 'postgres'
610import { afterAll , beforeAll , describe , expect , it , vi } from 'vitest'
711
8- const { databaseUrl, select } = vi . hoisted ( ( ) => {
12+ const { databaseUrl, select, transaction } = vi . hoisted ( ( ) => {
913 const databaseUrl = process . env . BILLING_USAGE_TEST_DATABASE_URL
1014 if ( databaseUrl && ! [ 'localhost' , '127.0.0.1' , '[::1]' ] . includes ( new URL ( databaseUrl ) . hostname ) ) {
1115 throw new Error ( 'Usage integration tests require a disposable local database' )
1216 }
13- return { databaseUrl, select : vi . fn ( ) }
17+ return { databaseUrl, select : vi . fn ( ) , transaction : vi . fn ( ) }
1418} )
1519vi . unmock ( 'drizzle-orm' )
1620vi . unmock ( '@sim/db/schema' )
17- vi . mock ( '@sim/db' , ( ) => ( { db : { select } , dbReplica : { select } } ) )
21+ vi . mock ( '@sim/db' , ( ) => ( { db : { select, transaction } , dbReplica : { select } } ) )
22+ vi . mock ( '@sim/audit' , async ( original ) => ( {
23+ ...( await original < typeof import ( '@sim/audit' ) > ( ) ) ,
24+ recordAudit : vi . fn ( ) ,
25+ } ) )
1826vi . mock ( '@/lib/billing/core/billing' , ( ) => ( { getOrganizationSubscription : vi . fn ( ) } ) )
1927
28+ import { updateOrganizationMemberUsageLimit } from '@/lib/billing/application/member-usage-limits/use-cases'
2029import { isOrgMemberUsageLimitTarget } from '@/lib/billing/organizations/member-limits'
30+ import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership'
2131
2232const schemaName = `member_limits_${ generateId ( ) . replaceAll ( '-' , '' ) } `
2333const connection = databaseUrl
2434 ? postgres ( databaseUrl , {
25- max : 1 ,
35+ max : 3 ,
2636 prepare : false ,
27- connection : { search_path : schemaName } ,
37+ connection : { search_path : schemaName , application_name : schemaName } ,
2838 onnotice : ( ) => undefined ,
2939 } )
3040 : undefined
3141
42+ const database = connection ? drizzle ( connection , { schema } ) : undefined
43+
3244beforeAll ( async ( ) => {
3345 if ( ! connection ) return
3446 await connection . unsafe ( `CREATE SCHEMA "${ schemaName } "` )
3547 await connection . unsafe ( `
36- CREATE TABLE member (id text PRIMARY KEY, organization_id text, user_id text);
48+ CREATE TABLE member (id text PRIMARY KEY, organization_id text, user_id text, role text DEFAULT 'member' );
3749 CREATE TABLE workspace (id text PRIMARY KEY, organization_id text, archived_at timestamp);
3850 CREATE TABLE permissions (id text PRIMARY KEY, user_id text, entity_type text, entity_id text);
3951 CREATE TABLE "user" (id text PRIMARY KEY);
4052 INSERT INTO "user" VALUES ('unrelated');
41- INSERT INTO member VALUES
53+ INSERT INTO member (id, organization_id, user_id) VALUES
4254 ('m1', 'org', 'member'), ('m2', 'other', 'external'), ('m3', 'other', 'foreign-member');
55+ INSERT INTO member VALUES ('actor', 'org', 'actor', 'admin');
56+ CREATE TABLE organization_member_usage_limit (
57+ id text PRIMARY KEY, organization_id text, user_id text, usage_limit numeric,
58+ set_by text, created_at timestamp DEFAULT now(), updated_at timestamp DEFAULT now(),
59+ UNIQUE(organization_id, user_id)
60+ );
4361 INSERT INTO workspace VALUES
4462 ('local', 'org', null), ('foreign', 'other', null), ('archived', 'org', now());
4563 INSERT INTO permissions VALUES
@@ -50,11 +68,13 @@ beforeAll(async () => {
5068 ('p5', 'missing-workspace', 'workspace', 'missing'),
5169 ('p6', 'revoked', 'workspace', 'local');
5270 ` )
53- const database = drizzle ( connection )
54- select . mockImplementation ( ( fields ) => database . select ( fields ) )
71+ select . mockImplementation ( ( fields ) => database ! . select ( fields ) )
72+ transaction . mockImplementation ( ( callback ) => database ! . transaction ( callback ) )
73+ setEnvFlags ( { isHosted : true } )
5574} )
5675
5776afterAll ( async ( ) => {
77+ resetEnvFlagsMock ( )
5878 if ( ! connection ) return
5979 await connection . unsafe ( `DROP SCHEMA "${ schemaName } " CASCADE` )
6080 await connection . end ( )
@@ -81,3 +101,78 @@ describe.skipIf(!databaseUrl)('organization credit-limit target SQL', () => {
81101 expect ( await isOrgMemberUsageLimitTarget ( 'org' , 'revoked' ) ) . toBe ( false )
82102 } )
83103} )
104+
105+ describe . skipIf ( ! databaseUrl ) ( 'organization credit-limit mutation races' , ( ) => {
106+ it . each ( [ 'member' , 'external' , 'archived-external' ] ) (
107+ 'sets and clears a cap for the eligible target %s' ,
108+ async ( userId ) => {
109+ const principal = { kind : 'session' , userId : 'actor' , sessionId : 'session' } as const
110+ await expect (
111+ updateOrganizationMemberUsageLimit . execute ( {
112+ principal,
113+ input : { organizationId : 'org' , userId, creditLimit : 400 } ,
114+ } )
115+ ) . resolves . toEqual ( { creditLimit : 400 } )
116+ const [ cap ] =
117+ await connection ! `SELECT usage_limit, set_by FROM organization_member_usage_limit WHERE user_id = ${ userId } `
118+ expect ( Number ( cap . usage_limit ) ) . toBe ( 2 )
119+ expect ( cap . set_by ) . toBe ( 'actor' )
120+ await expect (
121+ updateOrganizationMemberUsageLimit . execute ( {
122+ principal,
123+ input : { organizationId : 'org' , userId, creditLimit : null } ,
124+ } )
125+ ) . resolves . toEqual ( { creditLimit : null } )
126+ expect (
127+ await connection ! `SELECT id FROM organization_member_usage_limit WHERE user_id = ${ userId } `
128+ ) . toHaveLength ( 0 )
129+ }
130+ )
131+
132+ it . each ( [ 'organization-removal' , 'workspace-revocation' ] as const ) (
133+ 'rejects a target revoked by %s while the update waits' ,
134+ async ( removalKind ) => {
135+ const userId = `target-${ removalKind } `
136+ await connection ! `INSERT INTO permissions VALUES (${ userId } , ${ userId } , 'workspace', 'local')`
137+ vi . mocked ( recordAudit ) . mockClear ( )
138+ const ready = Promise . withResolvers < void > ( )
139+ const release = Promise . withResolvers < void > ( )
140+ const removal = database ! . transaction ( async ( tx ) => {
141+ if ( removalKind === 'organization-removal' ) {
142+ await acquireOrganizationUserMutationLocks ( tx , { userId, organizationIds : [ 'org' ] } )
143+ }
144+ await tx . delete ( schema . permissions ) . where ( eq ( schema . permissions . userId , userId ) )
145+ ready . resolve ( )
146+ await release . promise
147+ } )
148+ await ready . promise
149+ const update = updateOrganizationMemberUsageLimit
150+ . execute ( {
151+ principal : { kind : 'session' , userId : 'actor' , sessionId : 'session' } ,
152+ input : { organizationId : 'org' , userId, creditLimit : 400 } ,
153+ } )
154+ . then (
155+ ( result ) => ( { result } ) ,
156+ ( error : unknown ) => ( { error } )
157+ )
158+ try {
159+ await vi . waitFor (
160+ async ( ) => {
161+ const [ waiting ] = await connection ! `SELECT count(*)::int AS count FROM pg_stat_activity
162+ WHERE application_name = ${ schemaName } AND wait_event_type = 'Lock'`
163+ expect ( waiting . count ) . toBeGreaterThan ( 0 )
164+ } ,
165+ { timeout : 2000 }
166+ )
167+ } finally {
168+ release . resolve ( )
169+ await removal
170+ }
171+ expect ( await update ) . toMatchObject ( { error : { code : 'not_found' } } )
172+ const caps =
173+ await connection ! `SELECT id FROM organization_member_usage_limit WHERE user_id = ${ userId } `
174+ expect ( caps ) . toHaveLength ( 0 )
175+ expect ( recordAudit ) . not . toHaveBeenCalled ( )
176+ }
177+ )
178+ } )
0 commit comments