Skip to content

Commit 39041f6

Browse files
authored
Merge pull request #296 from BigBen-7/feat/be-34-36-37-39-tests-swagger-export
[BE-34/36/37/39] Unit tests, e2e tests, Swagger decorators, document export
2 parents c7578a0 + a15b45a commit 39041f6

10 files changed

Lines changed: 469 additions & 45 deletions

backend/src/auth/auth.controller.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {
1+
import {
22
Body,
33
Controller,
44
Post,
@@ -8,6 +8,7 @@
88
UseGuards,
99
BadRequestException,
1010
} from '@nestjs/common';
11+
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
1112
import { ConfigService } from '@nestjs/config';
1213
import { AuthGuard } from '@nestjs/passport';
1314
import { Request, Response } from 'express';
@@ -19,6 +20,7 @@ import { RegisterAuthDto } from './dto/register-auth.dto';
1920
import { LoginAuthDto } from './dto/login-auth.dto';
2021
import { RefreshAuthDto } from './dto/refresh-auth.dto';
2122

23+
@ApiTags('Authentication')
2224
@Controller('auth')
2325
export class AuthController {
2426
constructor(
@@ -27,28 +29,41 @@ export class AuthController {
2729
) {}
2830

2931
@Post('register')
32+
@ApiOperation({ summary: 'Register a new user' })
33+
@ApiResponse({ status: 201, description: 'User registered successfully' })
34+
@ApiResponse({ status: 409, description: 'Email already in use' })
3035
register(@Body() dto: RegisterAuthDto) {
3136
return this.authService.register(dto);
3237
}
3338

3439
@Post('login')
40+
@ApiOperation({ summary: 'Login with email and password' })
41+
@ApiResponse({ status: 200, description: 'Returns access and refresh tokens' })
42+
@ApiResponse({ status: 401, description: 'Invalid credentials' })
3543
login(@Body() dto: LoginAuthDto) {
3644
return this.authService.login(dto);
3745
}
3846

3947
@Post('refresh')
48+
@ApiOperation({ summary: 'Refresh access token' })
49+
@ApiResponse({ status: 200, description: 'Returns a new access token' })
50+
@ApiResponse({ status: 401, description: 'Invalid or expired refresh token' })
4051
refresh(@Body() dto: RefreshAuthDto) {
4152
return this.authService.refreshToken(dto);
4253
}
4354

4455
@Get('google')
4556
@UseGuards(AuthGuard('google'))
57+
@ApiOperation({ summary: 'Initiate Google OAuth login' })
58+
@ApiResponse({ status: 302, description: 'Redirects to Google login' })
4659
googleAuth() {
4760
return;
4861
}
4962

5063
@Get('google/callback')
5164
@UseGuards(AuthGuard('google'))
65+
@ApiOperation({ summary: 'Google OAuth callback' })
66+
@ApiResponse({ status: 302, description: 'Redirects to frontend with token' })
5267
async googleAuthRedirect(
5368
@Req() req: Request & { user?: GoogleProfile },
5469
@Res() res: Response,
@@ -77,20 +92,24 @@ export class AuthController {
7792

7893
@Get('github')
7994
@UseGuards(AuthGuard('github'))
95+
@ApiOperation({ summary: 'Initiate GitHub OAuth login' })
96+
@ApiResponse({ status: 302, description: 'Redirects to GitHub login' })
8097
githubAuth() {
8198
return;
8299
}
83100

84101
@Get('github/callback')
85102
@UseGuards(AuthGuard('github'))
103+
@ApiOperation({ summary: 'GitHub OAuth callback' })
104+
@ApiResponse({ status: 302, description: 'Redirects to frontend with token' })
86105
async githubAuthRedirect(
87106
@Req() req: Request & { user?: GithubProfile },
88107
@Res() res: Response,
89108
) {
90109
const profile = req.user;
91110
const githubId = profile?.id?.toString();
92111
const email = profile?.emails?.[0]?.value;
93-
const identifier = email || (githubId ? github: : null);
112+
const identifier = email || (githubId ? `github:${githubId}` : null);
94113
if (!identifier) {
95114
throw new BadRequestException('GitHub profile could not be identified');
96115
}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import { IsEmail, IsNotEmpty } from 'class-validator';
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsEmail, IsNotEmpty } from 'class-validator';
23

34
export class LoginAuthDto {
5+
@ApiProperty({ example: 'user@example.com' })
46
@IsEmail()
57
email: string;
68

9+
@ApiProperty({ example: 'password123' })
710
@IsNotEmpty()
811
password: string;
912
}
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { IsNotEmpty } from 'class-validator';
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty } from 'class-validator';
23

34
export class RefreshAuthDto {
5+
@ApiProperty({ description: 'JWT refresh token' })
46
@IsNotEmpty()
57
refreshToken: string;
68
}
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1-
import { IsEmail, IsNotEmpty, MinLength } from 'class-validator';
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsEmail, IsNotEmpty, MinLength } from 'class-validator';
23

34
export class RegisterAuthDto {
5+
@ApiProperty({ example: 'user@example.com' })
46
@IsEmail()
57
email: string;
68

9+
@ApiProperty({ example: 'password123', minLength: 6 })
710
@IsNotEmpty()
811
@MinLength(6)
912
password: string;
1013

14+
@ApiProperty({ example: 'Jane Doe' })
1115
@IsNotEmpty()
1216
fullName: string;
1317
}

backend/src/documents/documents.controller.ts

Lines changed: 98 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
BadRequestException,
33
ConflictException,
44
Controller,
5+
ForbiddenException,
56
Get,
67
NotFoundException,
78
Param,
@@ -15,11 +16,14 @@ import {
1516
} from '@nestjs/common';
1617
import { FileInterceptor } from '@nestjs/platform-express';
1718
import { ConfigService } from '@nestjs/config';
19+
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
1820
import { Request, Response } from 'express';
1921
import { createHash } from 'crypto';
2022
import { promises as fs } from 'fs';
2123
import { extname, join } from 'path';
2224
import * as multer from 'multer';
25+
import * as PDFDocument from 'pdfkit';
26+
import * as ExcelJS from 'exceljs';
2327

2428
import { DocumentsService } from './documents.service';
2529
import { DocumentStatus } from './entities/document.entity';
@@ -38,10 +42,11 @@ const fileFilter: multer.FileFilterCallback = (_req, file, callback) => {
3842
if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
3943
return callback(null, true);
4044
}
41-
4245
return callback(new BadRequestException('Only PDF, PNG, or JPEG files are allowed'), false);
4346
};
4447

48+
@ApiTags('Documents')
49+
@ApiBearerAuth('JWT-auth')
4550
@Controller('documents')
4651
export class DocumentsController {
4752
constructor(
@@ -68,38 +73,31 @@ export class DocumentsController {
6873

6974
@Post('upload')
7075
@UseGuards(JwtAuthGuard)
71-
@UseInterceptors(
72-
FileInterceptor('file', {
73-
storage: multerStorage,
74-
fileFilter,
75-
limits: { fileSize: MAX_FILE_SIZE_BYTES },
76-
}),
77-
)
76+
@UseInterceptors(FileInterceptor('file', { storage: multerStorage, fileFilter, limits: { fileSize: MAX_FILE_SIZE_BYTES } }))
77+
@ApiOperation({ summary: 'Upload a document for analysis' })
78+
@ApiConsumes('multipart/form-data')
79+
@ApiResponse({ status: 202, description: 'Document accepted for processing' })
80+
@ApiResponse({ status: 200, description: 'Document already exists (duplicate)' })
81+
@ApiResponse({ status: 400, description: 'Invalid file type or missing file' })
82+
@ApiResponse({ status: 401, description: 'Unauthenticated' })
7883
async uploadDocument(
7984
@UploadedFile() file: Express.Multer.File,
8085
@Req() req: Request & { user?: User },
8186
@Res() res: Response,
8287
) {
83-
if (!file) {
84-
throw new BadRequestException('File is required');
85-
}
88+
if (!file) throw new BadRequestException('File is required');
8689

8790
const user = req.user;
88-
if (!user) {
89-
throw new BadRequestException('Authenticated user is required');
90-
}
91+
if (!user) throw new BadRequestException('Authenticated user is required');
9192

9293
const fileHash = createHash('sha256').update(file.buffer).digest('hex');
9394
const existing = await this.documentsService.findByFileHash(fileHash);
94-
if (existing) {
95-
return res.status(200).send(existing);
96-
}
95+
if (existing) return res.status(200).send(existing);
9796

9897
const uploadDir = this.configService.get<string>('UPLOAD_DIR') || './uploads';
9998
await fs.mkdir(uploadDir, { recursive: true });
10099

101-
const extension = extname(file.originalname) || '';
102-
const filename = `${fileHash}${extension}`;
100+
const filename = `${fileHash}${extname(file.originalname) || ''}`;
103101
const targetPath = join(uploadDir, filename);
104102
await fs.writeFile(targetPath, file.buffer);
105103

@@ -117,39 +115,101 @@ export class DocumentsController {
117115
return res.status(202).send(document);
118116
}
119117

118+
// BE-39: Excel export — static route must come before dynamic :id routes
119+
@Get('export/excel')
120+
@UseGuards(JwtAuthGuard)
121+
@ApiOperation({ summary: 'Export all user documents as Excel (.xlsx)' })
122+
@ApiResponse({ status: 200, description: 'Excel file download' })
123+
async exportExcel(@Req() req: Request & { user?: User }, @Res() res: Response) {
124+
const documents = await this.documentsService.findByOwner(req.user!.id);
125+
126+
const workbook = new ExcelJS.Workbook();
127+
const sheet = workbook.addWorksheet('Documents');
128+
sheet.columns = [
129+
{ header: 'ID', key: 'id', width: 36 },
130+
{ header: 'Title', key: 'title', width: 30 },
131+
{ header: 'Status', key: 'status', width: 12 },
132+
{ header: 'Risk Score', key: 'riskScore', width: 12 },
133+
{ header: 'Risk Flags', key: 'riskFlags', width: 40 },
134+
{ header: 'Uploaded At', key: 'createdAt', width: 24 },
135+
];
136+
documents.forEach((doc) =>
137+
sheet.addRow({
138+
...doc,
139+
riskFlags: doc.riskFlags?.join(', ') ?? '',
140+
}),
141+
);
142+
143+
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
144+
res.setHeader('Content-Disposition', 'attachment; filename="documents.xlsx"');
145+
await workbook.xlsx.write(res);
146+
res.end();
147+
}
148+
120149
@Post(':id/verify')
121150
@UseGuards(JwtAuthGuard)
122-
async verifyDocument(@Param('id') id: string, @Res() res: Response) {
151+
@ApiOperation({ summary: 'Queue a document for Stellar blockchain verification' })
152+
@ApiResponse({ status: 202, description: 'Verification queued' })
153+
@ApiResponse({ status: 403, description: 'Not the document owner' })
154+
@ApiResponse({ status: 404, description: 'Document not found' })
155+
@ApiResponse({ status: 409, description: 'Already verified' })
156+
async verifyDocument(
157+
@Param('id') id: string,
158+
@Req() req: Request & { user?: User },
159+
@Res() res: Response,
160+
) {
123161
const document = await this.documentsService.findById(id);
124-
if (!document) {
125-
throw new NotFoundException('Document not found');
126-
}
127-
128-
if (document.status === DocumentStatus.VERIFIED) {
129-
throw new ConflictException('Document has already been verified');
130-
}
162+
if (!document) throw new NotFoundException('Document not found');
163+
if (document.ownerId !== req.user?.id) throw new ForbiddenException('Access denied');
164+
if (document.status === DocumentStatus.VERIFIED) throw new ConflictException('Document has already been verified');
131165

132166
await this.queueService.enqueueAnchor(document.id);
133-
134-
return res.status(202).json({
135-
message: 'Verification queued',
136-
documentId: document.id,
137-
});
167+
return res.status(202).json({ message: 'Verification queued', documentId: document.id });
138168
}
139169

140170
@Get(':id/verification')
141171
@UseGuards(JwtAuthGuard)
172+
@ApiOperation({ summary: 'Get the latest verification record for a document' })
173+
@ApiResponse({ status: 200, description: 'Verification record' })
174+
@ApiResponse({ status: 404, description: 'Document or record not found' })
142175
async getVerification(@Param('id') id: string) {
143176
const document = await this.documentsService.findById(id);
144-
if (!document) {
145-
throw new NotFoundException('Document not found');
146-
}
177+
if (!document) throw new NotFoundException('Document not found');
147178

148179
const record = await this.verificationService.findLatestByDocument(id);
149-
if (!record) {
150-
throw new NotFoundException('No verification record found for this document');
151-
}
180+
if (!record) throw new NotFoundException('No verification record found for this document');
152181

153182
return record;
154183
}
184+
185+
// BE-39: PDF export
186+
@Get(':id/export/pdf')
187+
@UseGuards(JwtAuthGuard)
188+
@ApiOperation({ summary: 'Export a single document report as PDF' })
189+
@ApiResponse({ status: 200, description: 'PDF file download' })
190+
@ApiResponse({ status: 404, description: 'Document not found' })
191+
async exportPdf(@Param('id') id: string, @Res() res: Response) {
192+
const document = await this.documentsService.findById(id);
193+
if (!document) throw new NotFoundException('Document not found');
194+
195+
const verification = await this.verificationService.findLatestByDocument(id);
196+
197+
const doc = new PDFDocument({ margin: 50 });
198+
res.setHeader('Content-Type', 'application/pdf');
199+
res.setHeader('Content-Disposition', `attachment; filename="${document.title}.pdf"`);
200+
doc.pipe(res);
201+
202+
doc.fontSize(18).text('Document Report', { align: 'center' }).moveDown();
203+
doc.fontSize(12)
204+
.text(`Title: ${document.title}`)
205+
.text(`Uploaded: ${document.createdAt.toISOString()}`)
206+
.text(`Status: ${document.status}`)
207+
.text(`Risk Score: ${document.riskScore ?? 'N/A'}`)
208+
.text(`Risk Flags: ${document.riskFlags?.join(', ') || 'None'}`)
209+
.moveDown()
210+
.text(`Stellar Tx Hash: ${verification?.stellarTxHash ?? 'Not anchored'}`)
211+
.text(`Stellar Ledger: ${verification?.stellarLedger ?? 'N/A'}`);
212+
213+
doc.end();
214+
}
155215
}

backend/src/risk-assessment/risk-assessment.controller.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1-
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
1+
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
2+
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
23

34
import { RiskAssessmentService } from './risk-assessment.service';
45
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
56

7+
@ApiTags('Documents')
8+
@ApiBearerAuth('JWT-auth')
69
@Controller('documents')
710
export class RiskAssessmentController {
811
constructor(private readonly riskService: RiskAssessmentService) {}
912

1013
@Get(':id/risk')
1114
@UseGuards(JwtAuthGuard)
15+
@ApiOperation({ summary: 'Get risk assessment for a document' })
16+
@ApiResponse({ status: 200, description: 'Risk score and flags' })
17+
@ApiResponse({ status: 404, description: 'Document not found' })
1218
async getRisk(@Param('id') id: string) {
1319
return this.riskService.assessDocument(id);
1420
}

0 commit comments

Comments
 (0)