Added Documents Feature

This commit is contained in:
Eric Gullickson
2025-09-28 20:35:46 -05:00
parent 2e1b588270
commit 775a1ff69e
66 changed files with 5655 additions and 944 deletions

View File

@@ -0,0 +1,435 @@
/**
* @ai-summary Integration tests for Documents API endpoints
* @ai-context Tests full API flow with auth, database, and storage
*/
import request from 'supertest';
import { FastifyInstance } from 'fastify';
import { build } from '../../../../app';
import path from 'path';
import fs from 'fs';
describe('Documents Integration Tests', () => {
let app: FastifyInstance;
let testUserId: string;
let testVehicleId: string;
let authToken: string;
beforeAll(async () => {
app = build({ logger: false });
await app.ready();
// Create test user context
testUserId = 'test-user-' + Date.now();
authToken = 'Bearer test-token';
// Create test vehicle for document association
const vehicleData = {
vin: '1HGBH41JXMN109186',
nickname: 'Test Car',
color: 'Blue',
odometerReading: 50000,
};
const vehicleResponse = await request(app.server)
.post('/api/vehicles')
.set('Authorization', authToken)
.send(vehicleData);
testVehicleId = vehicleResponse.body.id;
});
afterAll(async () => {
await app.close();
});
describe('POST /api/documents', () => {
it('should create document metadata', async () => {
const documentData = {
vehicle_id: testVehicleId,
document_type: 'insurance',
title: 'Car Insurance Policy',
notes: 'Annual policy',
details: { provider: 'State Farm', policy_number: '12345' },
issued_date: '2024-01-01',
expiration_date: '2024-12-31',
};
const response = await request(app.server)
.post('/api/documents')
.set('Authorization', authToken)
.send(documentData)
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(String),
user_id: testUserId,
vehicle_id: testVehicleId,
document_type: 'insurance',
title: 'Car Insurance Policy',
notes: 'Annual policy',
details: { provider: 'State Farm', policy_number: '12345' },
issued_date: '2024-01-01',
expiration_date: '2024-12-31',
created_at: expect.any(String),
updated_at: expect.any(String),
});
// Storage fields should be null initially
expect(response.body.storage_bucket).toBeNull();
expect(response.body.storage_key).toBeNull();
expect(response.body.file_name).toBeNull();
});
it('should reject document for non-existent vehicle', async () => {
const documentData = {
vehicle_id: 'non-existent-vehicle',
document_type: 'registration',
title: 'Invalid Document',
};
await request(app.server)
.post('/api/documents')
.set('Authorization', authToken)
.send(documentData)
.expect(403);
});
it('should require authentication', async () => {
const documentData = {
vehicle_id: testVehicleId,
document_type: 'insurance',
title: 'Unauthorized Document',
};
await request(app.server)
.post('/api/documents')
.send(documentData)
.expect(401);
});
});
describe('GET /api/documents', () => {
let testDocumentId: string;
beforeEach(async () => {
// Create test document
const documentData = {
vehicle_id: testVehicleId,
document_type: 'registration',
title: 'Test Document for Listing',
};
const response = await request(app.server)
.post('/api/documents')
.set('Authorization', authToken)
.send(documentData);
testDocumentId = response.body.id;
});
it('should list user documents', async () => {
const response = await request(app.server)
.get('/api/documents')
.set('Authorization', authToken)
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
expect(response.body.some((doc: any) => doc.id === testDocumentId)).toBe(true);
});
it('should filter documents by vehicle', async () => {
const response = await request(app.server)
.get('/api/documents')
.query({ vehicleId: testVehicleId })
.set('Authorization', authToken)
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
response.body.forEach((doc: any) => {
expect(doc.vehicle_id).toBe(testVehicleId);
});
});
it('should filter documents by type', async () => {
const response = await request(app.server)
.get('/api/documents')
.query({ type: 'registration' })
.set('Authorization', authToken)
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
response.body.forEach((doc: any) => {
expect(doc.document_type).toBe('registration');
});
});
});
describe('GET /api/documents/:id', () => {
let testDocumentId: string;
beforeEach(async () => {
const documentData = {
vehicle_id: testVehicleId,
document_type: 'insurance',
title: 'Single Document Test',
};
const response = await request(app.server)
.post('/api/documents')
.set('Authorization', authToken)
.send(documentData);
testDocumentId = response.body.id;
});
it('should get single document', async () => {
const response = await request(app.server)
.get(`/api/documents/${testDocumentId}`)
.set('Authorization', authToken)
.expect(200);
expect(response.body).toMatchObject({
id: testDocumentId,
user_id: testUserId,
vehicle_id: testVehicleId,
title: 'Single Document Test',
});
});
it('should return 404 for non-existent document', async () => {
await request(app.server)
.get('/api/documents/non-existent-id')
.set('Authorization', authToken)
.expect(404);
});
});
describe('PUT /api/documents/:id', () => {
let testDocumentId: string;
beforeEach(async () => {
const documentData = {
vehicle_id: testVehicleId,
document_type: 'insurance',
title: 'Document to Update',
notes: 'Original notes',
};
const response = await request(app.server)
.post('/api/documents')
.set('Authorization', authToken)
.send(documentData);
testDocumentId = response.body.id;
});
it('should update document metadata', async () => {
const updateData = {
title: 'Updated Document Title',
notes: 'Updated notes',
details: { updated: true },
};
const response = await request(app.server)
.put(`/api/documents/${testDocumentId}`)
.set('Authorization', authToken)
.send(updateData)
.expect(200);
expect(response.body).toMatchObject({
id: testDocumentId,
title: 'Updated Document Title',
notes: 'Updated notes',
details: { updated: true },
updated_at: expect.any(String),
});
});
it('should return 404 for non-existent document', async () => {
await request(app.server)
.put('/api/documents/non-existent-id')
.set('Authorization', authToken)
.send({ title: 'New Title' })
.expect(404);
});
});
describe('File Upload/Download Flow', () => {
let testDocumentId: string;
let testFilePath: string;
beforeAll(() => {
// Create test file
testFilePath = path.join(__dirname, 'test-file.pdf');
fs.writeFileSync(testFilePath, 'Fake PDF content for testing');
});
afterAll(() => {
// Clean up test file
if (fs.existsSync(testFilePath)) {
fs.unlinkSync(testFilePath);
}
});
beforeEach(async () => {
const documentData = {
vehicle_id: testVehicleId,
document_type: 'insurance',
title: 'Document for Upload Test',
};
const response = await request(app.server)
.post('/api/documents')
.set('Authorization', authToken)
.send(documentData);
testDocumentId = response.body.id;
});
it('should upload file to document', async () => {
const response = await request(app.server)
.post(`/api/documents/${testDocumentId}/upload`)
.set('Authorization', authToken)
.attach('file', testFilePath)
.expect(200);
expect(response.body).toMatchObject({
id: testDocumentId,
storage_bucket: expect.any(String),
storage_key: expect.any(String),
file_name: 'test-file.pdf',
content_type: expect.any(String),
file_size: expect.any(Number),
});
expect(response.body.storage_key).toMatch(/^documents\//);
});
it('should reject unsupported file types', async () => {
// Create temporary executable file
const execPath = path.join(__dirname, 'test.exe');
fs.writeFileSync(execPath, 'fake executable');
try {
await request(app.server)
.post(`/api/documents/${testDocumentId}/upload`)
.set('Authorization', authToken)
.attach('file', execPath)
.expect(415);
} finally {
if (fs.existsSync(execPath)) {
fs.unlinkSync(execPath);
}
}
});
it('should download uploaded file', async () => {
// First upload a file
await request(app.server)
.post(`/api/documents/${testDocumentId}/upload`)
.set('Authorization', authToken)
.attach('file', testFilePath);
// Then download it
const response = await request(app.server)
.get(`/api/documents/${testDocumentId}/download`)
.set('Authorization', authToken)
.expect(200);
expect(response.headers['content-disposition']).toContain('test-file.pdf');
expect(response.body.toString()).toBe('Fake PDF content for testing');
});
it('should return 404 for download without uploaded file', async () => {
await request(app.server)
.get(`/api/documents/${testDocumentId}/download`)
.set('Authorization', authToken)
.expect(404);
});
});
describe('DELETE /api/documents/:id', () => {
let testDocumentId: string;
beforeEach(async () => {
const documentData = {
vehicle_id: testVehicleId,
document_type: 'registration',
title: 'Document to Delete',
};
const response = await request(app.server)
.post('/api/documents')
.set('Authorization', authToken)
.send(documentData);
testDocumentId = response.body.id;
});
it('should soft delete document', async () => {
await request(app.server)
.delete(`/api/documents/${testDocumentId}`)
.set('Authorization', authToken)
.expect(204);
// Verify document is no longer accessible
await request(app.server)
.get(`/api/documents/${testDocumentId}`)
.set('Authorization', authToken)
.expect(404);
});
it('should return 404 for already deleted document', async () => {
// Delete once
await request(app.server)
.delete(`/api/documents/${testDocumentId}`)
.set('Authorization', authToken)
.expect(204);
// Try to delete again
await request(app.server)
.delete(`/api/documents/${testDocumentId}`)
.set('Authorization', authToken)
.expect(204); // Idempotent behavior
});
});
describe('Authorization and Ownership', () => {
let otherUserDocumentId: string;
beforeEach(async () => {
// Create document as different user
const documentData = {
vehicle_id: testVehicleId,
document_type: 'insurance',
title: 'Other User Document',
};
// Mock different user context
const otherUserToken = 'Bearer other-user-token';
const response = await request(app.server)
.post('/api/documents')
.set('Authorization', otherUserToken)
.send(documentData);
otherUserDocumentId = response.body.id;
});
it('should not allow access to other users documents', async () => {
await request(app.server)
.get(`/api/documents/${otherUserDocumentId}`)
.set('Authorization', authToken)
.expect(404);
});
it('should not allow update of other users documents', async () => {
await request(app.server)
.put(`/api/documents/${otherUserDocumentId}`)
.set('Authorization', authToken)
.send({ title: 'Hacked Title' })
.expect(404);
});
});
});

View File

@@ -0,0 +1,333 @@
/**
* @ai-summary Unit tests for DocumentsRepository
* @ai-context Tests database layer with mocked pool
*/
import { DocumentsRepository } from '../../data/documents.repository';
import type { Pool } from 'pg';
describe('DocumentsRepository', () => {
let repository: DocumentsRepository;
let mockPool: jest.Mocked<Pool>;
beforeEach(() => {
mockPool = {
query: jest.fn(),
} as any;
repository = new DocumentsRepository(mockPool);
});
describe('insert', () => {
const mockDocumentData = {
id: 'doc-123',
user_id: 'user-123',
vehicle_id: 'vehicle-123',
document_type: 'insurance' as const,
title: 'Test Document',
notes: 'Test notes',
details: { provider: 'Test Provider' },
issued_date: '2024-01-01',
expiration_date: '2024-12-31',
};
it('should insert document with all fields', async () => {
const mockResult = { rows: [{ ...mockDocumentData, created_at: '2024-01-01T00:00:00Z' }] };
mockPool.query.mockResolvedValue(mockResult);
const result = await repository.insert(mockDocumentData);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO documents'),
[
'doc-123',
'user-123',
'vehicle-123',
'insurance',
'Test Document',
'Test notes',
{ provider: 'Test Provider' },
'2024-01-01',
'2024-12-31',
]
);
expect(result).toEqual(mockResult.rows[0]);
});
it('should insert document with null optional fields', async () => {
const minimalData = {
id: 'doc-123',
user_id: 'user-123',
vehicle_id: 'vehicle-123',
document_type: 'registration' as const,
title: 'Test Document',
};
const mockResult = { rows: [{ ...minimalData, notes: null, details: null }] };
mockPool.query.mockResolvedValue(mockResult);
const result = await repository.insert(minimalData);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO documents'),
[
'doc-123',
'user-123',
'vehicle-123',
'registration',
'Test Document',
null,
null,
null,
null,
]
);
expect(result).toEqual(mockResult.rows[0]);
});
});
describe('findById', () => {
it('should find document by id and user', async () => {
const mockDocument = { id: 'doc-123', user_id: 'user-123', title: 'Test' };
mockPool.query.mockResolvedValue({ rows: [mockDocument] });
const result = await repository.findById('doc-123', 'user-123');
expect(mockPool.query).toHaveBeenCalledWith(
'SELECT * FROM documents WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL',
['doc-123', 'user-123']
);
expect(result).toEqual(mockDocument);
});
it('should return null if document not found', async () => {
mockPool.query.mockResolvedValue({ rows: [] });
const result = await repository.findById('doc-123', 'user-123');
expect(result).toBeNull();
});
});
describe('listByUser', () => {
const mockDocuments = [
{ id: 'doc-1', user_id: 'user-123', title: 'Doc 1' },
{ id: 'doc-2', user_id: 'user-123', title: 'Doc 2' },
];
it('should list all user documents without filters', async () => {
mockPool.query.mockResolvedValue({ rows: mockDocuments });
const result = await repository.listByUser('user-123');
expect(mockPool.query).toHaveBeenCalledWith(
'SELECT * FROM documents WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC',
['user-123']
);
expect(result).toEqual(mockDocuments);
});
it('should list documents with vehicleId filter', async () => {
mockPool.query.mockResolvedValue({ rows: [mockDocuments[0]] });
const result = await repository.listByUser('user-123', { vehicleId: 'vehicle-123' });
expect(mockPool.query).toHaveBeenCalledWith(
'SELECT * FROM documents WHERE user_id = $1 AND deleted_at IS NULL AND vehicle_id = $2 ORDER BY created_at DESC',
['user-123', 'vehicle-123']
);
expect(result).toEqual([mockDocuments[0]]);
});
it('should list documents with type filter', async () => {
mockPool.query.mockResolvedValue({ rows: [mockDocuments[0]] });
const result = await repository.listByUser('user-123', { type: 'insurance' });
expect(mockPool.query).toHaveBeenCalledWith(
'SELECT * FROM documents WHERE user_id = $1 AND deleted_at IS NULL AND document_type = $2 ORDER BY created_at DESC',
['user-123', 'insurance']
);
expect(result).toEqual([mockDocuments[0]]);
});
it('should list documents with expiresBefore filter', async () => {
mockPool.query.mockResolvedValue({ rows: [mockDocuments[0]] });
const result = await repository.listByUser('user-123', { expiresBefore: '2024-12-31' });
expect(mockPool.query).toHaveBeenCalledWith(
'SELECT * FROM documents WHERE user_id = $1 AND deleted_at IS NULL AND expiration_date <= $2 ORDER BY created_at DESC',
['user-123', '2024-12-31']
);
expect(result).toEqual([mockDocuments[0]]);
});
it('should list documents with multiple filters', async () => {
mockPool.query.mockResolvedValue({ rows: [mockDocuments[0]] });
const result = await repository.listByUser('user-123', {
vehicleId: 'vehicle-123',
type: 'insurance',
expiresBefore: '2024-12-31',
});
expect(mockPool.query).toHaveBeenCalledWith(
'SELECT * FROM documents WHERE user_id = $1 AND deleted_at IS NULL AND vehicle_id = $2 AND document_type = $3 AND expiration_date <= $4 ORDER BY created_at DESC',
['user-123', 'vehicle-123', 'insurance', '2024-12-31']
);
expect(result).toEqual([mockDocuments[0]]);
});
});
describe('softDelete', () => {
it('should soft delete document', async () => {
mockPool.query.mockResolvedValue({ rows: [] });
await repository.softDelete('doc-123', 'user-123');
expect(mockPool.query).toHaveBeenCalledWith(
'UPDATE documents SET deleted_at = NOW() WHERE id = $1 AND user_id = $2',
['doc-123', 'user-123']
);
});
});
describe('updateMetadata', () => {
it('should update single field', async () => {
const mockUpdated = { id: 'doc-123', title: 'Updated Title' };
mockPool.query.mockResolvedValue({ rows: [mockUpdated] });
const result = await repository.updateMetadata('doc-123', 'user-123', { title: 'Updated Title' });
expect(mockPool.query).toHaveBeenCalledWith(
'UPDATE documents SET title = $1 WHERE id = $2 AND user_id = $3 AND deleted_at IS NULL RETURNING *',
['Updated Title', 'doc-123', 'user-123']
);
expect(result).toEqual(mockUpdated);
});
it('should update multiple fields', async () => {
const mockUpdated = { id: 'doc-123', title: 'Updated Title', notes: 'Updated notes' };
mockPool.query.mockResolvedValue({ rows: [mockUpdated] });
const result = await repository.updateMetadata('doc-123', 'user-123', {
title: 'Updated Title',
notes: 'Updated notes',
details: { key: 'value' },
});
expect(mockPool.query).toHaveBeenCalledWith(
'UPDATE documents SET title = $1, notes = $2, details = $3 WHERE id = $4 AND user_id = $5 AND deleted_at IS NULL RETURNING *',
['Updated Title', 'Updated notes', { key: 'value' }, 'doc-123', 'user-123']
);
expect(result).toEqual(mockUpdated);
});
it('should handle null values', async () => {
const mockUpdated = { id: 'doc-123', notes: null };
mockPool.query.mockResolvedValue({ rows: [mockUpdated] });
const result = await repository.updateMetadata('doc-123', 'user-123', { notes: null });
expect(mockPool.query).toHaveBeenCalledWith(
'UPDATE documents SET notes = $1 WHERE id = $2 AND user_id = $3 AND deleted_at IS NULL RETURNING *',
[null, 'doc-123', 'user-123']
);
expect(result).toEqual(mockUpdated);
});
it('should return existing record if no fields to update', async () => {
const mockExisting = { id: 'doc-123', title: 'Existing' };
mockPool.query.mockResolvedValue({ rows: [mockExisting] });
const result = await repository.updateMetadata('doc-123', 'user-123', {});
expect(mockPool.query).toHaveBeenCalledWith(
'SELECT * FROM documents WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL',
['doc-123', 'user-123']
);
expect(result).toEqual(mockExisting);
});
it('should return null if document not found', async () => {
mockPool.query.mockResolvedValue({ rows: [] });
const result = await repository.updateMetadata('doc-123', 'user-123', { title: 'New Title' });
expect(result).toBeNull();
});
});
describe('updateStorageMeta', () => {
it('should update storage metadata', async () => {
const storageMeta = {
storage_bucket: 'test-bucket',
storage_key: 'test-key',
file_name: 'test.pdf',
content_type: 'application/pdf',
file_size: 1024,
file_hash: 'hash123',
};
const mockUpdated = { id: 'doc-123', ...storageMeta };
mockPool.query.mockResolvedValue({ rows: [mockUpdated] });
const result = await repository.updateStorageMeta('doc-123', 'user-123', storageMeta);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('UPDATE documents SET'),
[
'test-bucket',
'test-key',
'test.pdf',
'application/pdf',
1024,
'hash123',
'doc-123',
'user-123',
]
);
expect(result).toEqual(mockUpdated);
});
it('should handle null file_hash', async () => {
const storageMeta = {
storage_bucket: 'test-bucket',
storage_key: 'test-key',
file_name: 'test.pdf',
content_type: 'application/pdf',
file_size: 1024,
};
mockPool.query.mockResolvedValue({ rows: [{ id: 'doc-123', ...storageMeta, file_hash: null }] });
const result = await repository.updateStorageMeta('doc-123', 'user-123', storageMeta);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('UPDATE documents SET'),
[
'test-bucket',
'test-key',
'test.pdf',
'application/pdf',
1024,
null,
'doc-123',
'user-123',
]
);
});
it('should return null if document not found', async () => {
const storageMeta = {
storage_bucket: 'test-bucket',
storage_key: 'test-key',
file_name: 'test.pdf',
content_type: 'application/pdf',
file_size: 1024,
};
mockPool.query.mockResolvedValue({ rows: [] });
const result = await repository.updateStorageMeta('doc-123', 'user-123', storageMeta);
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,261 @@
/**
* @ai-summary Unit tests for DocumentsService
* @ai-context Tests business logic with mocked dependencies
*/
import { DocumentsService } from '../../domain/documents.service';
import { DocumentsRepository } from '../../data/documents.repository';
import pool from '../../../../core/config/database';
// Mock dependencies
jest.mock('../../data/documents.repository');
jest.mock('../../../../core/config/database');
const mockRepository = jest.mocked(DocumentsRepository);
const mockPool = jest.mocked(pool);
describe('DocumentsService', () => {
let service: DocumentsService;
let repositoryInstance: jest.Mocked<DocumentsRepository>;
beforeEach(() => {
jest.clearAllMocks();
repositoryInstance = {
insert: jest.fn(),
findById: jest.fn(),
listByUser: jest.fn(),
updateMetadata: jest.fn(),
updateStorageMeta: jest.fn(),
softDelete: jest.fn(),
} as any;
mockRepository.mockImplementation(() => repositoryInstance);
service = new DocumentsService();
});
describe('createDocument', () => {
const mockDocumentBody = {
vehicle_id: 'vehicle-123',
document_type: 'insurance' as const,
title: 'Car Insurance Policy',
notes: 'Annual insurance policy',
details: { provider: 'State Farm' },
issued_date: '2024-01-01',
expiration_date: '2024-12-31',
};
const mockCreatedDocument = {
id: 'doc-123',
user_id: 'user-123',
vehicle_id: 'vehicle-123',
document_type: 'insurance' as const,
title: 'Car Insurance Policy',
notes: 'Annual insurance policy',
details: { provider: 'State Farm' },
storage_bucket: null,
storage_key: null,
file_name: null,
content_type: null,
file_size: null,
file_hash: null,
issued_date: '2024-01-01',
expiration_date: '2024-12-31',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
deleted_at: null,
};
it('should create a document successfully', async () => {
// Mock vehicle ownership check
mockPool.query.mockResolvedValue({ rows: [{ id: 'vehicle-123' }] });
repositoryInstance.insert.mockResolvedValue(mockCreatedDocument);
const result = await service.createDocument('user-123', mockDocumentBody);
expect(mockPool.query).toHaveBeenCalledWith(
'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2',
['vehicle-123', 'user-123']
);
expect(repositoryInstance.insert).toHaveBeenCalledWith({
id: expect.any(String),
user_id: 'user-123',
vehicle_id: 'vehicle-123',
document_type: 'insurance',
title: 'Car Insurance Policy',
notes: 'Annual insurance policy',
details: { provider: 'State Farm' },
issued_date: '2024-01-01',
expiration_date: '2024-12-31',
});
expect(result).toEqual(mockCreatedDocument);
});
it('should create document with minimal data', async () => {
const minimalBody = {
vehicle_id: 'vehicle-123',
document_type: 'registration' as const,
title: 'Vehicle Registration',
};
mockPool.query.mockResolvedValue({ rows: [{ id: 'vehicle-123' }] });
repositoryInstance.insert.mockResolvedValue({
...mockCreatedDocument,
document_type: 'registration',
title: 'Vehicle Registration',
notes: null,
details: null,
issued_date: null,
expiration_date: null,
});
const result = await service.createDocument('user-123', minimalBody);
expect(repositoryInstance.insert).toHaveBeenCalledWith({
id: expect.any(String),
user_id: 'user-123',
vehicle_id: 'vehicle-123',
document_type: 'registration',
title: 'Vehicle Registration',
notes: null,
details: null,
issued_date: null,
expiration_date: null,
});
});
it('should reject document for non-owned vehicle', async () => {
mockPool.query.mockResolvedValue({ rows: [] });
await expect(service.createDocument('user-123', mockDocumentBody))
.rejects.toThrow('Vehicle not found or not owned by user');
expect(mockPool.query).toHaveBeenCalledWith(
'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2',
['vehicle-123', 'user-123']
);
expect(repositoryInstance.insert).not.toHaveBeenCalled();
});
it('should generate unique IDs for documents', async () => {
mockPool.query.mockResolvedValue({ rows: [{ id: 'vehicle-123' }] });
repositoryInstance.insert.mockResolvedValue(mockCreatedDocument);
await service.createDocument('user-123', mockDocumentBody);
await service.createDocument('user-123', mockDocumentBody);
expect(repositoryInstance.insert).toHaveBeenCalledTimes(2);
const firstCall = repositoryInstance.insert.mock.calls[0][0];
const secondCall = repositoryInstance.insert.mock.calls[1][0];
expect(firstCall.id).not.toEqual(secondCall.id);
expect(firstCall.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
});
});
describe('getDocument', () => {
it('should return document if found', async () => {
const mockDocument = {
id: 'doc-123',
user_id: 'user-123',
title: 'Test Document',
};
repositoryInstance.findById.mockResolvedValue(mockDocument as any);
const result = await service.getDocument('user-123', 'doc-123');
expect(repositoryInstance.findById).toHaveBeenCalledWith('doc-123', 'user-123');
expect(result).toEqual(mockDocument);
});
it('should return null if document not found', async () => {
repositoryInstance.findById.mockResolvedValue(null);
const result = await service.getDocument('user-123', 'doc-123');
expect(repositoryInstance.findById).toHaveBeenCalledWith('doc-123', 'user-123');
expect(result).toBeNull();
});
});
describe('listDocuments', () => {
const mockDocuments = [
{ id: 'doc-1', title: 'Insurance', document_type: 'insurance' },
{ id: 'doc-2', title: 'Registration', document_type: 'registration' },
];
it('should list all user documents without filters', async () => {
repositoryInstance.listByUser.mockResolvedValue(mockDocuments as any);
const result = await service.listDocuments('user-123');
expect(repositoryInstance.listByUser).toHaveBeenCalledWith('user-123', undefined);
expect(result).toEqual(mockDocuments);
});
it('should list documents with filters', async () => {
const filters = {
vehicleId: 'vehicle-123',
type: 'insurance' as const,
expiresBefore: '2024-12-31',
};
repositoryInstance.listByUser.mockResolvedValue([mockDocuments[0]] as any);
const result = await service.listDocuments('user-123', filters);
expect(repositoryInstance.listByUser).toHaveBeenCalledWith('user-123', filters);
expect(result).toEqual([mockDocuments[0]]);
});
});
describe('updateDocument', () => {
const mockExistingDocument = {
id: 'doc-123',
user_id: 'user-123',
title: 'Original Title',
};
it('should update document successfully', async () => {
const updateData = { title: 'Updated Title', notes: 'Updated notes' };
const updatedDocument = { ...mockExistingDocument, ...updateData };
repositoryInstance.findById.mockResolvedValue(mockExistingDocument as any);
repositoryInstance.updateMetadata.mockResolvedValue(updatedDocument as any);
const result = await service.updateDocument('user-123', 'doc-123', updateData);
expect(repositoryInstance.findById).toHaveBeenCalledWith('doc-123', 'user-123');
expect(repositoryInstance.updateMetadata).toHaveBeenCalledWith('doc-123', 'user-123', updateData);
expect(result).toEqual(updatedDocument);
});
it('should return null if document not found', async () => {
repositoryInstance.findById.mockResolvedValue(null);
const result = await service.updateDocument('user-123', 'doc-123', { title: 'New Title' });
expect(repositoryInstance.findById).toHaveBeenCalledWith('doc-123', 'user-123');
expect(repositoryInstance.updateMetadata).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should return existing document if no valid patch provided', async () => {
repositoryInstance.findById.mockResolvedValue(mockExistingDocument as any);
const result = await service.updateDocument('user-123', 'doc-123', null as any);
expect(repositoryInstance.findById).toHaveBeenCalledWith('doc-123', 'user-123');
expect(repositoryInstance.updateMetadata).not.toHaveBeenCalled();
expect(result).toEqual(mockExistingDocument);
});
});
describe('deleteDocument', () => {
it('should delete document successfully', async () => {
repositoryInstance.softDelete.mockResolvedValue(undefined);
await service.deleteDocument('user-123', 'doc-123');
expect(repositoryInstance.softDelete).toHaveBeenCalledWith('doc-123', 'user-123');
});
});
});

View File

@@ -0,0 +1,256 @@
/**
* @ai-summary Unit tests for MinIO storage adapter
* @ai-context Tests storage layer with mocked MinIO client
*/
import { createMinioAdapter } from '../../../../core/storage/adapters/minio.adapter';
import { Client as MinioClient } from 'minio';
import { appConfig } from '../../../../core/config/config-loader';
import { Readable } from 'stream';
// Mock dependencies
jest.mock('minio');
jest.mock('../../../../core/config/config-loader');
const mockMinioClient = jest.mocked(MinioClient);
const mockAppConfig = jest.mocked(appConfig);
describe('MinIO Storage Adapter', () => {
let clientInstance: jest.Mocked<MinioClient>;
let adapter: ReturnType<typeof createMinioAdapter>;
beforeEach(() => {
jest.clearAllMocks();
clientInstance = {
putObject: jest.fn(),
getObject: jest.fn(),
removeObject: jest.fn(),
statObject: jest.fn(),
presignedGetObject: jest.fn(),
presignedPutObject: jest.fn(),
} as any;
mockMinioClient.mockImplementation(() => clientInstance);
mockAppConfig.getMinioConfig.mockReturnValue({
endpoint: 'localhost',
port: 9000,
accessKey: 'testkey',
secretKey: 'testsecret',
bucket: 'test-bucket',
});
adapter = createMinioAdapter();
});
describe('putObject', () => {
it('should upload Buffer with correct parameters', async () => {
const buffer = Buffer.from('test content');
clientInstance.putObject.mockResolvedValue('etag-123');
await adapter.putObject('test-bucket', 'test-key', buffer, 'text/plain', { 'x-custom': 'value' });
expect(clientInstance.putObject).toHaveBeenCalledWith(
'test-bucket',
'test-key',
buffer,
buffer.length,
{
'Content-Type': 'text/plain',
'x-custom': 'value',
}
);
});
it('should upload string with correct parameters', async () => {
const content = 'test content';
clientInstance.putObject.mockResolvedValue('etag-123');
await adapter.putObject('test-bucket', 'test-key', content, 'text/plain');
expect(clientInstance.putObject).toHaveBeenCalledWith(
'test-bucket',
'test-key',
content,
content.length,
{ 'Content-Type': 'text/plain' }
);
});
it('should upload stream without size', async () => {
const stream = new Readable();
clientInstance.putObject.mockResolvedValue('etag-123');
await adapter.putObject('test-bucket', 'test-key', stream, 'application/octet-stream');
expect(clientInstance.putObject).toHaveBeenCalledWith(
'test-bucket',
'test-key',
stream,
undefined,
{ 'Content-Type': 'application/octet-stream' }
);
});
it('should handle upload without content type', async () => {
const buffer = Buffer.from('test');
clientInstance.putObject.mockResolvedValue('etag-123');
await adapter.putObject('test-bucket', 'test-key', buffer);
expect(clientInstance.putObject).toHaveBeenCalledWith(
'test-bucket',
'test-key',
buffer,
buffer.length,
{}
);
});
});
describe('getObjectStream', () => {
it('should return object stream', async () => {
const mockStream = new Readable();
clientInstance.getObject.mockResolvedValue(mockStream);
const result = await adapter.getObjectStream('test-bucket', 'test-key');
expect(clientInstance.getObject).toHaveBeenCalledWith('test-bucket', 'test-key');
expect(result).toBe(mockStream);
});
});
describe('deleteObject', () => {
it('should remove object', async () => {
clientInstance.removeObject.mockResolvedValue(undefined);
await adapter.deleteObject('test-bucket', 'test-key');
expect(clientInstance.removeObject).toHaveBeenCalledWith('test-bucket', 'test-key');
});
});
describe('headObject', () => {
it('should return object metadata', async () => {
const mockStat = {
size: 1024,
etag: 'test-etag',
lastModified: '2024-01-01T00:00:00Z',
metaData: {
'content-type': 'application/pdf',
'x-custom-header': 'custom-value',
},
};
clientInstance.statObject.mockResolvedValue(mockStat);
const result = await adapter.headObject('test-bucket', 'test-key');
expect(clientInstance.statObject).toHaveBeenCalledWith('test-bucket', 'test-key');
expect(result).toEqual({
size: 1024,
etag: 'test-etag',
lastModified: new Date('2024-01-01T00:00:00Z'),
contentType: 'application/pdf',
metadata: mockStat.metaData,
});
});
it('should handle metadata with Content-Type header', async () => {
const mockStat = {
size: 1024,
etag: 'test-etag',
lastModified: '2024-01-01T00:00:00Z',
metaData: {
'Content-Type': 'image/jpeg',
},
};
clientInstance.statObject.mockResolvedValue(mockStat);
const result = await adapter.headObject('test-bucket', 'test-key');
expect(result.contentType).toBe('image/jpeg');
});
it('should handle missing optional fields', async () => {
const mockStat = {
size: 1024,
etag: 'test-etag',
};
clientInstance.statObject.mockResolvedValue(mockStat);
const result = await adapter.headObject('test-bucket', 'test-key');
expect(result).toEqual({
size: 1024,
etag: 'test-etag',
lastModified: undefined,
contentType: undefined,
metadata: undefined,
});
});
});
describe('getSignedUrl', () => {
it('should generate GET signed URL with default expiry', async () => {
clientInstance.presignedGetObject.mockResolvedValue('https://example.com/signed-url');
const result = await adapter.getSignedUrl('test-bucket', 'test-key');
expect(clientInstance.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'test-key', 300);
expect(result).toBe('https://example.com/signed-url');
});
it('should generate GET signed URL with custom expiry', async () => {
clientInstance.presignedGetObject.mockResolvedValue('https://example.com/signed-url');
const result = await adapter.getSignedUrl('test-bucket', 'test-key', {
method: 'GET',
expiresSeconds: 600,
});
expect(clientInstance.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'test-key', 600);
expect(result).toBe('https://example.com/signed-url');
});
it('should generate PUT signed URL', async () => {
clientInstance.presignedPutObject.mockResolvedValue('https://example.com/put-url');
const result = await adapter.getSignedUrl('test-bucket', 'test-key', {
method: 'PUT',
expiresSeconds: 300,
});
expect(clientInstance.presignedPutObject).toHaveBeenCalledWith('test-bucket', 'test-key', 300);
expect(result).toBe('https://example.com/put-url');
});
it('should enforce minimum expiry time', async () => {
clientInstance.presignedGetObject.mockResolvedValue('https://example.com/signed-url');
await adapter.getSignedUrl('test-bucket', 'test-key', { expiresSeconds: 0 });
expect(clientInstance.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'test-key', 1);
});
it('should enforce maximum expiry time', async () => {
clientInstance.presignedGetObject.mockResolvedValue('https://example.com/signed-url');
await adapter.getSignedUrl('test-bucket', 'test-key', { expiresSeconds: 10000000 });
expect(clientInstance.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'test-key', 604800); // 7 days max
});
});
describe('MinioClient instantiation', () => {
it('should create client with correct configuration', () => {
expect(mockMinioClient).toHaveBeenCalledWith({
endPoint: 'localhost',
port: 9000,
useSSL: false,
accessKey: 'testkey',
secretKey: 'testsecret',
});
});
});
});