Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"uuid": "^13.0.0",
"zod": "^4.3.5"
},
"devDependencies": {
Expand All @@ -57,6 +58,7 @@
"@types/nodemailer": "^7.0.4",
"@types/pg": "^8.16.0",
"@types/supertest": "^6.0.2",
"@types/uuid": "^11.0.0",
"drizzle-kit": "^0.31.8",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
Expand Down Expand Up @@ -98,10 +100,10 @@
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"transformIgnorePatterns": [
"/node_modules/(?!(better-auth|better-call)/)"
"/node_modules/(?!(better-auth|better-call|uuid)/)"
],
"moduleNameMapper": {
"^better-auth/node$": "<rootDir>/test/mocks/better-auth-node.ts"
"^better-auth/node$": "<rootDir>/test/mocks/better-auth.mock.ts"
},
"coverageThreshold": {
"global": {
Expand Down
293 changes: 205 additions & 88 deletions apps/api/src/modules/system-admin/system-admin.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,68 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */

import { SystemAdminController } from './system-admin.controller';

import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from '../../db/schema';
import { BadRequestException, NotFoundException } from '@nestjs/common';

// Define mock as ANY to allow jest methods (mockReturnValue, etc)
// We only cast to NodePgDatabase when injecting into the controller.
const mockDb: any = {
interface MockDb {
query: {
user: {
findMany: jest.fn(),
},
organization: {
findMany: jest.fn(),
},
},
select: jest.fn(),
from: jest.fn(),
};

// Chain mocks
mockDb.select.mockReturnThis();
mockDb.from.mockImplementation(() => Promise.resolve([{ count: 5 }]));
user: { findMany: jest.Mock };
organization: { findFirst: jest.Mock; findMany: jest.Mock };
member: { findFirst: jest.Mock };
invitation: { findFirst: jest.Mock };
};
select: jest.Mock;
from: jest.Mock;
insert: jest.Mock;
update: jest.Mock;
delete: jest.Mock;
transaction: jest.Mock;
// Chain helpers
limit: jest.Mock;
offset: jest.Mock;
orderBy: jest.Mock;
groupBy: jest.Mock;
leftJoin: jest.Mock;
set: jest.Mock;
where: jest.Mock;
values: jest.Mock;
returning: jest.Mock;
}

describe('SystemAdminController', () => {
let controller: SystemAdminController;
let mockDb: MockDb;

beforeEach(() => {
jest.clearAllMocks();

// Reset mocks
mockDb.select.mockReturnThis();
mockDb.from.mockImplementation(() => Promise.resolve([{ count: 5 }]));
// Reset mocks with full structure
mockDb = {
query: {
user: { findMany: jest.fn() },
organization: { findFirst: jest.fn(), findMany: jest.fn() },
member: { findFirst: jest.fn() },
invitation: { findFirst: jest.fn() },
},
select: jest.fn().mockReturnThis(),
from: jest.fn().mockImplementation(() => Promise.resolve([{ count: 5 }])),
insert: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
transaction: jest.fn((cb) => cb(mockDb)), // Mock transaction execution
// Chain method definitions
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn(),
};

// Pure Unit Test
// Cast here to satisfy dependency injection signature
controller = new SystemAdminController(
mockDb as unknown as NodePgDatabase<typeof schema>,
);
Expand Down Expand Up @@ -73,47 +100,32 @@ describe('SystemAdminController', () => {
});

describe('listTenants', () => {
// Define strict interface for our chainable builder
interface MockQueryBuilder {
leftJoin: jest.Mock;
groupBy: jest.Mock;
limit: jest.Mock;
offset: jest.Mock;
orderBy: jest.Mock;
execute: () => Promise<unknown>;
}

const createMockBuilder = (result: unknown): MockQueryBuilder => ({
leftJoin: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue(result),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('should return paginated tenants with aggregated user count', async () => {
// Mock db.select chain for tenants
const mockResult = [{ id: 't1', name: 'Tenant 1', userCount: 2 }];

// We need to support two different select calls:
// 1. Tenants data (returns promise with array)
// 2. Count (returns promise with [{count: X}])
// Since select returns a chainable builder, we mock the final execution.
// However, `mockDb.select` is a global mock here.
// Easier way: mock implementation to inspect call args or return sequence.

mockDb.limit = jest.fn().mockReturnThis();
mockDb.offset = jest.fn().mockReturnThis();
mockDb.orderBy = jest.fn().mockReturnThis();
mockDb.groupBy = jest.fn().mockReturnThis();
mockDb.leftJoin = jest.fn().mockReturnThis();
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */
mockDb.from = jest.fn().mockReturnValue({
leftJoin: mockDb.leftJoin, // Chain continuation
limit: mockDb.limit,
offset: mockDb.offset,
groupBy: mockDb.groupBy,
orderBy: mockDb.orderBy,
then: (resolve: any) => resolve(mockResult), // Final await
});
/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */
// The second call for total count also calls .from()
// This is complex to mock cleanly with a shared `mockDb` object without conditional logic.
// Let's rely on `mockReturnValueOnce`?

// Call 1: Data
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */
mockDb.from.mockReturnValueOnce({
leftJoin: mockDb.leftJoin,
groupBy: mockDb.groupBy,
limit: mockDb.limit,
offset: mockDb.offset,
orderBy: mockDb.orderBy,
then: (resolve: any) => resolve(mockResult),
});
/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */
const mockQueryBuilder = createMockBuilder(mockResult);
mockDb.from.mockReturnValueOnce(mockQueryBuilder);

// Call 2: Total Count
mockDb.from.mockReturnValueOnce(Promise.resolve([{ count: 5 }]));
Expand All @@ -124,45 +136,150 @@ describe('SystemAdminController', () => {
data: [{ id: 't1', name: 'Tenant 1', userCount: 2 }],
total: 5,
});

// Verify specific builder usage
expect(mockQueryBuilder.limit).toHaveBeenCalledWith(10);
expect(mockQueryBuilder.offset).toHaveBeenCalledWith(0);
expect(mockQueryBuilder.execute).toHaveBeenCalled();
});

it('should clamp pagination parameters', async () => {
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */
mockDb.from.mockReturnValueOnce({
leftJoin: mockDb.leftJoin,
groupBy: mockDb.groupBy,
limit: mockDb.limit,
offset: mockDb.offset,
orderBy: mockDb.orderBy,
then: (resolve: any) => resolve([]),
});
/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */
const mockBuilder = createMockBuilder([]);

mockDb.from.mockReturnValueOnce(mockBuilder);
mockDb.from.mockReturnValueOnce(Promise.resolve([{ count: 0 }]));

// Request 1000 items (should clamp to 100)
await controller.listTenants('1', '1000');

expect(mockDb.limit).toHaveBeenCalledWith(100);
expect(mockDb.offset).toHaveBeenCalledWith(0);
expect(mockBuilder.limit).toHaveBeenCalledWith(100);
expect(mockBuilder.offset).toHaveBeenCalledWith(0);
expect(mockBuilder.execute).toHaveBeenCalled();
});
});

describe('createTenant', () => {
it('should throw BadRequestException if slug exists', async () => {
mockDb.query.organization.findFirst.mockResolvedValue({ id: 'existing' });

await expect(
controller.createTenant({
name: 'Test',
slug: 'test',
logo: '',
}),
).rejects.toThrow(BadRequestException);
});

it('should handle invalid/negative pagination inputs', async () => {
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */
mockDb.from.mockReturnValueOnce({
leftJoin: mockDb.leftJoin,
groupBy: mockDb.groupBy,
limit: mockDb.limit,
offset: mockDb.offset,
orderBy: mockDb.orderBy,
then: (resolve: any) => resolve([]),
it('should create tenant if slug is unique', async () => {
mockDb.query.organization.findFirst.mockResolvedValue(null);
mockDb.insert = jest.fn().mockReturnValue({
values: jest.fn().mockReturnValue({
returning: jest.fn().mockResolvedValue([{ id: 'new', slug: 'test' }]),
}),
});
/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */
mockDb.from.mockReturnValueOnce(Promise.resolve([{ count: 0 }]));

// Request invalid strings
await controller.listTenants('invalid', '-5');
const result = await controller.createTenant({
name: 'Test',
slug: 'test',
logo: '',
});

expect(result).toEqual({ id: 'new', slug: 'test' });
});
});

describe('updateTenant', () => {
it('should throw NotFoundException if tenant not found', async () => {
mockDb.query.organization.findFirst.mockResolvedValue(null);

await expect(
controller.updateTenant('missing', { name: 'New' }),
).rejects.toThrow(NotFoundException);
});

it('should throw BadRequestException if new slug overlaps', async () => {
mockDb.query.organization.findFirst
.mockResolvedValueOnce({ id: 't1', slug: 'old' }) // Existing target
.mockResolvedValueOnce({ id: 't2', slug: 'taken' }); // Collision check

await expect(
controller.updateTenant('t1', { slug: 'taken' }),
).rejects.toThrow(BadRequestException);
});

it('should throw BadRequestException if payload is empty', async () => {
mockDb.query.organization.findFirst.mockResolvedValue({ id: 't1' });
// Valid tenant, but empty update
await expect(controller.updateTenant('t1', {})).rejects.toThrow(
BadRequestException,
);
});

it('should update tenant successfully', async () => {
// 1. Find existing
mockDb.query.organization.findFirst
.mockResolvedValueOnce({ id: 't1', slug: 'old' }) // Existing
.mockResolvedValueOnce(null); // Collision check (slug change)

// 2. Update
mockDb.update = jest.fn().mockReturnValue({
set: jest.fn().mockReturnValue({
where: jest.fn().mockReturnValue({
returning: jest
.fn()
.mockResolvedValue([{ id: 't1', slug: 'new-slug', name: 'New' }]),
}),
}),
});

const result = await controller.updateTenant('t1', {
name: 'New',
slug: 'new-slug',
});

expect(result).toEqual({ id: 't1', slug: 'new-slug', name: 'New' });
// Verify update called with correct args
expect(mockDb.update).toHaveBeenCalled();
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe('deleteTenant', () => {
it('should throw NotFoundException if tenant not found', async () => {
mockDb.query.organization.findFirst.mockResolvedValue(null);

await expect(controller.deleteTenant('missing')).rejects.toThrow(
NotFoundException,
);
});

it('should delete tenant successfully inside transaction', async () => {
// 1. Find existing
mockDb.query.organization.findFirst.mockResolvedValue({ id: 't1' });

// 2. Setup Deletes
// We expect 3 deletes: member, invitation, organization
// We'll mock the delete chain to return a 'where' mock
const mockWhere = jest.fn().mockResolvedValue({});
mockDb.delete = jest.fn().mockReturnValue({
where: mockWhere,
});

await controller.deleteTenant('t1');

expect(mockDb.transaction).toHaveBeenCalled();

// Verify calls inside transaction
// Since it's inside transaction, we check the calls on mockDb (because our mock transaction calls cb(mockDb))
// Call 1: Member
expect(mockDb.delete).toHaveBeenNthCalledWith(1, schema.member);
// Call 2: Invitation
expect(mockDb.delete).toHaveBeenNthCalledWith(2, schema.invitation);
// Call 3: Organization
expect(mockDb.delete).toHaveBeenNthCalledWith(3, schema.organization);

expect(mockDb.limit).toHaveBeenCalledWith(1); // Clamps to 1
expect(mockDb.offset).toHaveBeenCalledWith(0); // Default page 1 -> 0
// Verify 3 executions of 'where'
expect(mockWhere).toHaveBeenCalledTimes(3);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
Loading