d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
109 lines
3.6 KiB
TypeScript
109 lines
3.6 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { mockReconcileOrganizationSeats, selectRows, mockFeatureFlags } = vi.hoisted(() => ({
|
|
mockReconcileOrganizationSeats: vi.fn(),
|
|
selectRows: { value: [] as unknown[] },
|
|
mockFeatureFlags: { isBillingEnabled: true },
|
|
}))
|
|
|
|
vi.mock('@sim/db', () => {
|
|
const makeChain = () => {
|
|
const chain: Record<string, unknown> = {}
|
|
chain.from = () => chain
|
|
chain.innerJoin = () => chain
|
|
chain.where = () => chain
|
|
chain.groupBy = () => chain
|
|
chain.having = () => chain
|
|
chain.orderBy = () => Promise.resolve(selectRows.value)
|
|
return chain
|
|
}
|
|
return { db: { select: () => makeChain() } }
|
|
})
|
|
|
|
vi.mock('@/lib/billing/organizations/seats', () => ({
|
|
reconcileOrganizationSeats: mockReconcileOrganizationSeats,
|
|
}))
|
|
|
|
vi.mock('@/lib/core/config/env-flags', () => ({
|
|
get isBillingEnabled() {
|
|
return mockFeatureFlags.isBillingEnabled
|
|
},
|
|
}))
|
|
|
|
import { reconcileTeamSeatDrift } from '@/lib/billing/organizations/seat-drift'
|
|
|
|
describe('reconcileTeamSeatDrift', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
selectRows.value = []
|
|
mockFeatureFlags.isBillingEnabled = true
|
|
mockReconcileOrganizationSeats.mockResolvedValue({ changed: true, previousSeats: 1, seats: 2 })
|
|
})
|
|
|
|
it('reconciles each drifted Team org returned by the query', async () => {
|
|
// The SQL WHERE (Team-only) + HAVING (seats != member count) already
|
|
// restrict the result to drifted Team orgs; the function reconciles each.
|
|
selectRows.value = [{ organizationId: 'org-1' }, { organizationId: 'org-2' }]
|
|
|
|
const result = await reconcileTeamSeatDrift()
|
|
|
|
expect(result).toEqual({ drifted: 2, reconciled: 2 })
|
|
expect(mockReconcileOrganizationSeats).toHaveBeenCalledTimes(2)
|
|
expect(mockReconcileOrganizationSeats).toHaveBeenCalledWith({
|
|
organizationId: 'org-1',
|
|
reason: 'seat-drift-sweep',
|
|
})
|
|
expect(mockReconcileOrganizationSeats).toHaveBeenCalledWith({
|
|
organizationId: 'org-2',
|
|
reason: 'seat-drift-sweep',
|
|
})
|
|
})
|
|
|
|
it('counts only reconciles that changed the seat count', async () => {
|
|
selectRows.value = [{ organizationId: 'org-a' }, { organizationId: 'org-b' }]
|
|
mockReconcileOrganizationSeats
|
|
.mockResolvedValueOnce({ changed: true, seats: 2 })
|
|
.mockResolvedValueOnce({ changed: false })
|
|
|
|
const result = await reconcileTeamSeatDrift()
|
|
|
|
expect(result).toEqual({ drifted: 2, reconciled: 1 })
|
|
})
|
|
|
|
it('continues past a reconcile failure', async () => {
|
|
selectRows.value = [{ organizationId: 'org-a' }, { organizationId: 'org-b' }]
|
|
mockReconcileOrganizationSeats
|
|
.mockRejectedValueOnce(new Error('db error'))
|
|
.mockResolvedValueOnce({ changed: true, seats: 3 })
|
|
|
|
const result = await reconcileTeamSeatDrift()
|
|
|
|
expect(result).toEqual({ drifted: 2, reconciled: 1 })
|
|
expect(mockReconcileOrganizationSeats).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('no-ops when billing is disabled', async () => {
|
|
mockFeatureFlags.isBillingEnabled = false
|
|
|
|
const result = await reconcileTeamSeatDrift()
|
|
|
|
expect(result).toEqual({ drifted: 0, reconciled: 0 })
|
|
expect(mockReconcileOrganizationSeats).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('caps reconciles per run while still reporting the full drift count', async () => {
|
|
selectRows.value = Array.from({ length: 150 }, (_, i) => ({
|
|
organizationId: `org-${i}`,
|
|
}))
|
|
|
|
const result = await reconcileTeamSeatDrift()
|
|
|
|
expect(result.drifted).toBe(150)
|
|
expect(result.reconciled).toBe(100)
|
|
expect(mockReconcileOrganizationSeats).toHaveBeenCalledTimes(100)
|
|
})
|
|
})
|