feat: add AccountsModule with list and QR endpoints

Implements GET /accounts (list all accounts) and GET /accounts/:id/qr
(returns QR code as base64 data URL) using the qrcode package.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 11:15:42 +05:30
parent 02dad1347c
commit 1dba77959d
8 changed files with 268 additions and 0 deletions
+2
View File
@@ -5,6 +5,7 @@ import { HealthModule } from './modules/health/health.module';
import { SearchModule } from './modules/search/search.module';
import { GroupsModule } from './modules/groups/groups.module';
import { RoutesModule } from './modules/routes/routes.module';
import { AccountsModule } from './modules/accounts/accounts.module';
@Module({
imports: [
@@ -14,6 +15,7 @@ import { RoutesModule } from './modules/routes/routes.module';
SearchModule,
GroupsModule,
RoutesModule,
AccountsModule,
],
})
export class AppModule {}
@@ -0,0 +1,36 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AccountsController } from './accounts.controller';
import { AccountsService } from './accounts.service';
const mockAccounts = [
{ id: 'acc_1', platform: 'whatsapp', jid: '111@s.whatsapp.net', displayName: 'Test', status: 'ACTIVE' },
];
const mockService = {
list: jest.fn().mockResolvedValue(mockAccounts),
getQr: jest.fn().mockResolvedValue({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,fake' }),
};
describe('AccountsController', () => {
let controller: AccountsController;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
controllers: [AccountsController],
providers: [{ provide: AccountsService, useValue: mockService }],
}).compile();
controller = module.get<AccountsController>(AccountsController);
});
it('list() returns accounts from service', async () => {
const result = await controller.list();
expect(result).toEqual(mockAccounts);
expect(mockService.list).toHaveBeenCalled();
});
it('getQr() calls service with the account id', async () => {
const result = await controller.getQr('acc_1');
expect(mockService.getQr).toHaveBeenCalledWith('acc_1');
expect(result.qrDataUrl).toBe('data:image/png;base64,fake');
});
});
@@ -0,0 +1,17 @@
import { Controller, Get, Param } from '@nestjs/common';
import { AccountsService } from './accounts.service';
@Controller('accounts')
export class AccountsController {
constructor(private readonly service: AccountsService) {}
@Get()
list() {
return this.service.list();
}
@Get(':id/qr')
getQr(@Param('id') id: string) {
return this.service.getQr(id);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AccountsController } from './accounts.controller';
import { AccountsService } from './accounts.service';
@Module({
controllers: [AccountsController],
providers: [AccountsService],
})
export class AccountsModule {}
@@ -0,0 +1,66 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AccountsService } from './accounts.service';
import { PrismaService } from '../../prisma/prisma.service';
import * as QRCode from 'qrcode';
jest.mock('qrcode', () => ({
toDataURL: jest.fn().mockResolvedValue('data:image/png;base64,fakedata'),
}));
const mockAccounts = [
{ id: 'acc_1', platform: 'whatsapp', jid: '111@s.whatsapp.net', displayName: 'Test Account', status: 'ACTIVE' },
];
const mockPrisma = {
account: {
findMany: jest.fn().mockResolvedValue(mockAccounts),
findUnique: jest.fn(),
},
};
describe('AccountsService', () => {
let service: AccountsService;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
AccountsService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<AccountsService>(AccountsService);
});
describe('list()', () => {
it('returns accounts from Prisma without qrCode field', async () => {
const result = await service.list();
expect(result).toEqual(mockAccounts);
expect(mockPrisma.account.findMany).toHaveBeenCalledWith(
expect.objectContaining({ select: expect.not.objectContaining({ qrCode: true }) }),
);
});
});
describe('getQr()', () => {
it('returns null qrDataUrl when account has no qrCode', async () => {
mockPrisma.account.findUnique.mockResolvedValue({ status: 'ACTIVE', qrCode: null });
const result = await service.getQr('acc_1');
expect(result).toEqual({ status: 'ACTIVE', qrDataUrl: null });
expect(QRCode.toDataURL).not.toHaveBeenCalled();
});
it('converts qrCode string to data URL when qrCode is present', async () => {
mockPrisma.account.findUnique.mockResolvedValue({ status: 'DISCONNECTED', qrCode: 'raw-qr-string' });
const result = await service.getQr('acc_1');
expect(QRCode.toDataURL).toHaveBeenCalledWith('raw-qr-string');
expect(result).toEqual({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,fakedata' });
});
it('returns not_found status when account does not exist', async () => {
mockPrisma.account.findUnique.mockResolvedValue(null);
const result = await service.getQr('nonexistent');
expect(result).toEqual({ status: 'not_found', qrDataUrl: null });
});
});
});
@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import * as QRCode from 'qrcode';
export interface AccountSummary {
id: string;
platform: string;
jid: string;
displayName: string | null;
status: string;
}
export interface AccountQr {
status: string;
qrDataUrl: string | null;
}
@Injectable()
export class AccountsService {
constructor(private readonly prisma: PrismaService) {}
list(): Promise<AccountSummary[]> {
return this.prisma.account.findMany({
orderBy: { createdAt: 'asc' },
select: { id: true, platform: true, jid: true, displayName: true, status: true },
});
}
async getQr(id: string): Promise<AccountQr> {
const account = await this.prisma.account.findUnique({
where: { id },
select: { status: true, qrCode: true },
});
if (!account) return { status: 'not_found', qrDataUrl: null };
if (!account.qrCode) return { status: account.status, qrDataUrl: null };
const qrDataUrl = await QRCode.toDataURL(account.qrCode);
return { status: account.status, qrDataUrl };
}
}