Adds the addAccountByToken procedure to accountProcedures(), which accepts an email and raw OAuth token, stores the token as claudeAiOauth credentials, and upserts the account (create or updateAccountAuth based on findByEmail). Covers the four scenarios with unit tests: new account, existing account, empty email, and empty token validation errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
297 lines
9.3 KiB
TypeScript
297 lines
9.3 KiB
TypeScript
/**
|
|
* tRPC Router Tests
|
|
*
|
|
* Tests for the tRPC procedures using createCallerFactory.
|
|
* Tests verify correct response shapes and Zod validation.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import {
|
|
appRouter,
|
|
createCallerFactory,
|
|
healthResponseSchema,
|
|
statusResponseSchema,
|
|
} from './index.js';
|
|
import type { TRPCContext } from './context.js';
|
|
import type { EventBus } from '../events/types.js';
|
|
import type { AccountRepository } from '../db/repositories/account-repository.js';
|
|
|
|
// Create caller factory for the app router
|
|
const createCaller = createCallerFactory(appRouter);
|
|
|
|
/**
|
|
* Create a mock EventBus for testing.
|
|
*/
|
|
function createMockEventBus(): EventBus {
|
|
return {
|
|
emit: vi.fn(),
|
|
on: vi.fn(),
|
|
off: vi.fn(),
|
|
once: vi.fn(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a test context with configurable options.
|
|
*/
|
|
function createTestContext(overrides: Partial<TRPCContext> = {}): TRPCContext {
|
|
return {
|
|
eventBus: createMockEventBus(),
|
|
serverStartedAt: new Date('2026-01-30T12:00:00Z'),
|
|
processCount: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('tRPC Router', () => {
|
|
let caller: ReturnType<typeof createCaller>;
|
|
let ctx: TRPCContext;
|
|
|
|
beforeEach(() => {
|
|
ctx = createTestContext();
|
|
caller = createCaller(ctx);
|
|
});
|
|
|
|
describe('health procedure', () => {
|
|
it('should return correct shape', async () => {
|
|
const result = await caller.health();
|
|
|
|
expect(result).toEqual({
|
|
status: 'ok',
|
|
uptime: expect.any(Number),
|
|
processCount: 0,
|
|
});
|
|
});
|
|
|
|
it('should validate against Zod schema', async () => {
|
|
const result = await caller.health();
|
|
|
|
const parsed = healthResponseSchema.safeParse(result);
|
|
expect(parsed.success).toBe(true);
|
|
});
|
|
|
|
it('should calculate uptime from serverStartedAt', async () => {
|
|
// Set serverStartedAt to 60 seconds ago
|
|
const sixtySecondsAgo = new Date(Date.now() - 60000);
|
|
ctx = createTestContext({ serverStartedAt: sixtySecondsAgo });
|
|
caller = createCaller(ctx);
|
|
|
|
const result = await caller.health();
|
|
|
|
// Uptime should be approximately 60 seconds (allow 1 second tolerance)
|
|
expect(result.uptime).toBeGreaterThanOrEqual(59);
|
|
expect(result.uptime).toBeLessThanOrEqual(61);
|
|
});
|
|
|
|
it('should return uptime 0 when serverStartedAt is null', async () => {
|
|
ctx = createTestContext({ serverStartedAt: null });
|
|
caller = createCaller(ctx);
|
|
|
|
const result = await caller.health();
|
|
|
|
expect(result.uptime).toBe(0);
|
|
});
|
|
|
|
it('should reflect processCount from context', async () => {
|
|
ctx = createTestContext({ processCount: 5 });
|
|
caller = createCaller(ctx);
|
|
|
|
const result = await caller.health();
|
|
|
|
expect(result.processCount).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe('status procedure', () => {
|
|
it('should return correct shape', async () => {
|
|
const result = await caller.status();
|
|
|
|
expect(result).toEqual({
|
|
server: {
|
|
startedAt: expect.any(String),
|
|
uptime: expect.any(Number),
|
|
pid: expect.any(Number),
|
|
},
|
|
processes: [],
|
|
});
|
|
});
|
|
|
|
it('should validate against Zod schema', async () => {
|
|
const result = await caller.status();
|
|
|
|
const parsed = statusResponseSchema.safeParse(result);
|
|
expect(parsed.success).toBe(true);
|
|
});
|
|
|
|
it('should include server startedAt as ISO string', async () => {
|
|
const result = await caller.status();
|
|
|
|
expect(result.server.startedAt).toBe('2026-01-30T12:00:00.000Z');
|
|
});
|
|
|
|
it('should return empty startedAt when serverStartedAt is null', async () => {
|
|
ctx = createTestContext({ serverStartedAt: null });
|
|
caller = createCaller(ctx);
|
|
|
|
const result = await caller.status();
|
|
|
|
expect(result.server.startedAt).toBe('');
|
|
});
|
|
|
|
it('should include actual process.pid', async () => {
|
|
const result = await caller.status();
|
|
|
|
expect(result.server.pid).toBe(process.pid);
|
|
});
|
|
|
|
it('should calculate uptime correctly', async () => {
|
|
const thirtySecondsAgo = new Date(Date.now() - 30000);
|
|
ctx = createTestContext({ serverStartedAt: thirtySecondsAgo });
|
|
caller = createCaller(ctx);
|
|
|
|
const result = await caller.status();
|
|
|
|
expect(result.server.uptime).toBeGreaterThanOrEqual(29);
|
|
expect(result.server.uptime).toBeLessThanOrEqual(31);
|
|
});
|
|
|
|
it('should return empty processes array', async () => {
|
|
const result = await caller.status();
|
|
|
|
expect(result.processes).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('addAccountByToken procedure', () => {
|
|
let mockRepo: AccountRepository;
|
|
|
|
beforeEach(() => {
|
|
mockRepo = {
|
|
findByEmail: vi.fn(),
|
|
updateAccountAuth: vi.fn(),
|
|
create: vi.fn(),
|
|
} as unknown as AccountRepository;
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('creates a new account and returns { upserted: false, account }', async () => {
|
|
const stubAccount = { id: 'new-id', email: 'user@example.com', provider: 'claude' };
|
|
(mockRepo.findByEmail as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
|
(mockRepo.create as ReturnType<typeof vi.fn>).mockResolvedValue(stubAccount);
|
|
|
|
const testCtx = createTestContext({ accountRepository: mockRepo });
|
|
const testCaller = createCaller(testCtx);
|
|
const result = await testCaller.addAccountByToken({ email: 'user@example.com', token: 'my-token' });
|
|
|
|
expect(result).toEqual({ upserted: false, account: stubAccount });
|
|
expect(mockRepo.create).toHaveBeenCalledWith({
|
|
email: 'user@example.com',
|
|
provider: 'claude',
|
|
configJson: '{"hasCompletedOnboarding":true}',
|
|
credentials: '{"claudeAiOauth":{"accessToken":"my-token"}}',
|
|
});
|
|
expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('updates existing account and returns { upserted: true, account }', async () => {
|
|
const existingAccount = { id: 'existing-id', email: 'user@example.com', provider: 'claude' };
|
|
const updatedAccount = { id: 'existing-id', email: 'user@example.com', provider: 'claude', updated: true };
|
|
(mockRepo.findByEmail as ReturnType<typeof vi.fn>).mockResolvedValue(existingAccount);
|
|
(mockRepo.updateAccountAuth as ReturnType<typeof vi.fn>).mockResolvedValue(updatedAccount);
|
|
|
|
const testCtx = createTestContext({ accountRepository: mockRepo });
|
|
const testCaller = createCaller(testCtx);
|
|
const result = await testCaller.addAccountByToken({ email: 'user@example.com', token: 'my-token' });
|
|
|
|
expect(result).toEqual({ upserted: true, account: updatedAccount });
|
|
expect(mockRepo.updateAccountAuth).toHaveBeenCalledWith(
|
|
'existing-id',
|
|
'{"hasCompletedOnboarding":true}',
|
|
'{"claudeAiOauth":{"accessToken":"my-token"}}',
|
|
);
|
|
expect(mockRepo.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws BAD_REQUEST when email is empty', async () => {
|
|
const testCtx = createTestContext({ accountRepository: mockRepo });
|
|
const testCaller = createCaller(testCtx);
|
|
|
|
await expect(testCaller.addAccountByToken({ email: '', provider: 'claude', token: 'tok' }))
|
|
.rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
expect(mockRepo.findByEmail).not.toHaveBeenCalled();
|
|
expect(mockRepo.create).not.toHaveBeenCalled();
|
|
expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws BAD_REQUEST when token is empty', async () => {
|
|
const testCtx = createTestContext({ accountRepository: mockRepo });
|
|
const testCaller = createCaller(testCtx);
|
|
|
|
await expect(testCaller.addAccountByToken({ email: 'user@example.com', provider: 'claude', token: '' }))
|
|
.rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
expect(mockRepo.findByEmail).not.toHaveBeenCalled();
|
|
expect(mockRepo.create).not.toHaveBeenCalled();
|
|
expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Zod schema validation', () => {
|
|
it('healthResponseSchema should reject invalid status', () => {
|
|
const invalid = {
|
|
status: 'not-ok',
|
|
uptime: 100,
|
|
processCount: 0,
|
|
};
|
|
|
|
const parsed = healthResponseSchema.safeParse(invalid);
|
|
expect(parsed.success).toBe(false);
|
|
});
|
|
|
|
it('healthResponseSchema should reject negative uptime', () => {
|
|
const invalid = {
|
|
status: 'ok',
|
|
uptime: -1,
|
|
processCount: 0,
|
|
};
|
|
|
|
const parsed = healthResponseSchema.safeParse(invalid);
|
|
expect(parsed.success).toBe(false);
|
|
});
|
|
|
|
it('statusResponseSchema should reject missing server fields', () => {
|
|
const invalid = {
|
|
server: {
|
|
startedAt: '2026-01-30T12:00:00Z',
|
|
// missing uptime and pid
|
|
},
|
|
processes: [],
|
|
};
|
|
|
|
const parsed = statusResponseSchema.safeParse(invalid);
|
|
expect(parsed.success).toBe(false);
|
|
});
|
|
|
|
it('statusResponseSchema should accept valid process info', () => {
|
|
const valid = {
|
|
server: {
|
|
startedAt: '2026-01-30T12:00:00Z',
|
|
uptime: 100,
|
|
pid: 12345,
|
|
},
|
|
processes: [
|
|
{
|
|
id: 'proc-1',
|
|
pid: 54321,
|
|
command: 'node server.js',
|
|
status: 'running',
|
|
startedAt: '2026-01-30T12:00:00Z',
|
|
},
|
|
],
|
|
};
|
|
|
|
const parsed = statusResponseSchema.safeParse(valid);
|
|
expect(parsed.success).toBe(true);
|
|
});
|
|
});
|
|
});
|