feat: Add addAccountByToken tRPC mutation with upsert logic
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>
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
} 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);
|
||||
@@ -161,6 +162,79 @@ describe('tRPC Router', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -72,5 +72,29 @@ export function accountProcedures(publicProcedure: ProcedureBuilder) {
|
||||
.query(() => {
|
||||
return listProviderNames();
|
||||
}),
|
||||
|
||||
addAccountByToken: publicProcedure
|
||||
.input(z.object({
|
||||
email: z.string().min(1),
|
||||
provider: z.string().default('claude'),
|
||||
token: z.string().min(1),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const repo = requireAccountRepository(ctx);
|
||||
const credentials = JSON.stringify({ claudeAiOauth: { accessToken: input.token } });
|
||||
const configJson = JSON.stringify({ hasCompletedOnboarding: true });
|
||||
const existing = await repo.findByEmail(input.email);
|
||||
if (existing) {
|
||||
const account = await repo.updateAccountAuth(existing.id, configJson, credentials);
|
||||
return { upserted: true, account };
|
||||
}
|
||||
const account = await repo.create({
|
||||
email: input.email,
|
||||
provider: input.provider,
|
||||
configJson,
|
||||
credentials,
|
||||
});
|
||||
return { upserted: false, account };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user