feat(testing): Implement testing foundation with strategy and first unit tests
Testing Strategy: - Created comprehensive testing strategy document - Target: 80%+ code coverage - Testing pyramid: Unit (70%) → Integration (20%) → E2E (10%) - Defined test data management and best practices Backend Unit Tests: - Created ComplianceService unit test suite (10 tests) - Tests for data export, account deletion, cancellation - Mock repository pattern for isolated testing - AAA pattern (Arrange, Act, Assert) Next Steps: - Run and fix unit tests - Create integration tests for API endpoints - Setup frontend testing with React Testing Library - Setup E2E tests with Playwright - Configure CI/CD pipeline with GitHub Actions - Achieve 80%+ code coverage Status: Testing foundation initiated (0% → 5% progress)
This commit is contained in:
54
maternal-app/maternal-app-backend/package-lock.json
generated
54
maternal-app/maternal-app-backend/package-lock.json
generated
@@ -30,6 +30,7 @@
|
||||
"@sentry/node": "^10.17.0",
|
||||
"@sentry/profiling-node": "^10.17.0",
|
||||
"@simplewebauthn/server": "^13.2.1",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@types/pdfkit": "^0.17.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"axios": "^1.12.2",
|
||||
@@ -45,6 +46,7 @@
|
||||
"langchain": "^0.3.35",
|
||||
"mailgun.js": "^12.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"node-fetch": "^2.7.0",
|
||||
"openai": "^6.0.1",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.7.0",
|
||||
@@ -6375,6 +6377,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-fetch": {
|
||||
"version": "2.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
|
||||
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"form-data": "^4.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport": {
|
||||
"version": "1.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
|
||||
@@ -12242,6 +12254,26 @@
|
||||
"lodash": "^4.17.21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
@@ -14787,6 +14819,12 @@
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
@@ -15459,6 +15497,12 @@
|
||||
"defaults": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.97.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz",
|
||||
@@ -15559,6 +15603,16 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"@sentry/node": "^10.17.0",
|
||||
"@sentry/profiling-node": "^10.17.0",
|
||||
"@simplewebauthn/server": "^13.2.1",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@types/pdfkit": "^0.17.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"axios": "^1.12.2",
|
||||
@@ -57,6 +58,7 @@
|
||||
"langchain": "^0.3.35",
|
||||
"mailgun.js": "^12.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"node-fetch": "^2.7.0",
|
||||
"openai": "^6.0.1",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.7.0",
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ComplianceService } from './compliance.service';
|
||||
import { User } from '../../database/entities/user.entity';
|
||||
import { DeletionRequest } from '../../database/entities/deletion-request.entity';
|
||||
import { FamilyMember } from '../../database/entities/family-member.entity';
|
||||
import { Child } from '../../database/entities/child.entity';
|
||||
import { Activity } from '../../database/entities/activity.entity';
|
||||
import { AIConversation } from '../../database/entities/ai-conversation.entity';
|
||||
import { Photo } from '../../database/entities/photo.entity';
|
||||
import { AuditLog } from '../../database/entities/audit-log.entity';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
|
||||
describe('ComplianceService', () => {
|
||||
let service: ComplianceService;
|
||||
let userRepository: Repository<User>;
|
||||
let deletionRequestRepository: Repository<DeletionRequest>;
|
||||
let auditLogRepository: Repository<AuditLog>;
|
||||
|
||||
const mockUser = {
|
||||
id: 'user_123',
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
dateOfBirth: new Date('1990-01-01'),
|
||||
createdAt: new Date('2024-01-01'),
|
||||
emailVerified: true,
|
||||
};
|
||||
|
||||
const mockUserRepository = {
|
||||
findOne: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const mockDeletionRequestRepository = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const mockAuditLogRepository = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const mockFamilyMemberRepository = {
|
||||
find: jest.fn(),
|
||||
};
|
||||
|
||||
const mockChildRepository = {
|
||||
find: jest.fn(),
|
||||
};
|
||||
|
||||
const mockActivityRepository = {
|
||||
find: jest.fn(),
|
||||
};
|
||||
|
||||
const mockAIConversationRepository = {
|
||||
find: jest.fn(),
|
||||
};
|
||||
|
||||
const mockPhotoRepository = {
|
||||
find: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ComplianceService,
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useValue: mockUserRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(DeletionRequest),
|
||||
useValue: mockDeletionRequestRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(FamilyMember),
|
||||
useValue: mockFamilyMemberRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Child),
|
||||
useValue: mockChildRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Activity),
|
||||
useValue: mockActivityRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AIConversation),
|
||||
useValue: mockAIConversationRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Photo),
|
||||
useValue: mockPhotoRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AuditLog),
|
||||
useValue: mockAuditLogRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ComplianceService>(ComplianceService);
|
||||
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
|
||||
deletionRequestRepository = module.get<Repository<DeletionRequest>>(
|
||||
getRepositoryToken(DeletionRequest),
|
||||
);
|
||||
auditLogRepository = module.get<Repository<AuditLog>>(
|
||||
getRepositoryToken(AuditLog),
|
||||
);
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('exportUserData', () => {
|
||||
it('should export all user data successfully', async () => {
|
||||
const mockFamilies = [
|
||||
{ id: 'fam_1', name: 'Doe Family', role: 'admin' },
|
||||
];
|
||||
const mockChildren = [
|
||||
{
|
||||
id: 'child_1',
|
||||
name: 'Jane Doe',
|
||||
dateOfBirth: new Date('2020-01-01'),
|
||||
gender: 'female',
|
||||
},
|
||||
];
|
||||
const mockActivities = [
|
||||
{
|
||||
id: 'act_1',
|
||||
type: 'feeding',
|
||||
timestamp: new Date(),
|
||||
data: { amount: 120, unit: 'ml' },
|
||||
},
|
||||
];
|
||||
const mockConversations = [
|
||||
{
|
||||
id: 'conv_1',
|
||||
createdAt: new Date(),
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'How much should my baby eat?',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue(mockUser);
|
||||
mockFamilyMemberRepository.find.mockResolvedValue(mockFamilies);
|
||||
mockChildRepository.find.mockResolvedValue(mockChildren);
|
||||
mockActivityRepository.find.mockResolvedValue(mockActivities);
|
||||
mockAIConversationRepository.find.mockResolvedValue(mockConversations);
|
||||
|
||||
const result = await service.exportUserData('user_123');
|
||||
|
||||
expect(result).toHaveProperty('user');
|
||||
expect(result).toHaveProperty('families');
|
||||
expect(result).toHaveProperty('children');
|
||||
expect(result).toHaveProperty('activities');
|
||||
expect(result).toHaveProperty('aiConversations');
|
||||
|
||||
expect(result.user.id).toBe('user_123');
|
||||
expect(result.user.email).toBe('john@example.com');
|
||||
expect(result.families).toHaveLength(1);
|
||||
expect(result.children).toHaveLength(1);
|
||||
expect(result.activities).toHaveLength(1);
|
||||
expect(result.aiConversations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when user does not exist', async () => {
|
||||
mockUserRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.exportUserData('nonexistent_user')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle user with no data', async () => {
|
||||
mockUserRepository.findOne.mockResolvedValue(mockUser);
|
||||
mockFamilyMemberRepository.find.mockResolvedValue([]);
|
||||
mockChildRepository.find.mockResolvedValue([]);
|
||||
mockActivityRepository.find.mockResolvedValue([]);
|
||||
mockAIConversationRepository.find.mockResolvedValue([]);
|
||||
|
||||
const result = await service.exportUserData('user_123');
|
||||
|
||||
expect(result.families).toEqual([]);
|
||||
expect(result.children).toEqual([]);
|
||||
expect(result.activities).toEqual([]);
|
||||
expect(result.aiConversations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestAccountDeletion', () => {
|
||||
it('should schedule account deletion with 30-day grace period', async () => {
|
||||
const mockDeletionRequest = {
|
||||
id: 'del_123',
|
||||
userId: 'user_123',
|
||||
requestedAt: new Date(),
|
||||
scheduledDeletionAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
|
||||
status: 'pending',
|
||||
reason: 'Testing',
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'Test Agent',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue(mockUser);
|
||||
mockDeletionRequestRepository.create.mockReturnValue(mockDeletionRequest);
|
||||
mockDeletionRequestRepository.save.mockResolvedValue(mockDeletionRequest);
|
||||
|
||||
const result = await service.requestAccountDeletion(
|
||||
'user_123',
|
||||
'Testing',
|
||||
'127.0.0.1',
|
||||
'Test Agent',
|
||||
);
|
||||
|
||||
expect(result.userId).toBe('user_123');
|
||||
expect(result.status).toBe('pending');
|
||||
expect(result.reason).toBe('Testing');
|
||||
|
||||
// Verify scheduled deletion is ~30 days from now
|
||||
const daysDiff = Math.floor(
|
||||
(result.scheduledDeletionAt.getTime() - Date.now()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
);
|
||||
expect(daysDiff).toBeGreaterThanOrEqual(29);
|
||||
expect(daysDiff).toBeLessThanOrEqual(30);
|
||||
});
|
||||
|
||||
it('should throw error if deletion request already exists', async () => {
|
||||
const existingRequest = {
|
||||
id: 'del_existing',
|
||||
userId: 'user_123',
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue(mockUser);
|
||||
mockDeletionRequestRepository.findOne.mockResolvedValue(
|
||||
existingRequest,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.requestAccountDeletion(
|
||||
'user_123',
|
||||
'Testing',
|
||||
'127.0.0.1',
|
||||
'Test Agent',
|
||||
),
|
||||
).rejects.toThrow('Deletion request already pending');
|
||||
});
|
||||
|
||||
it('should create audit log for deletion request', async () => {
|
||||
mockUserRepository.findOne.mockResolvedValue(mockUser);
|
||||
mockDeletionRequestRepository.create.mockReturnValue({});
|
||||
mockDeletionRequestRepository.save.mockResolvedValue({});
|
||||
mockAuditLogRepository.create.mockReturnValue({});
|
||||
mockAuditLogRepository.save.mockResolvedValue({});
|
||||
|
||||
await service.requestAccountDeletion(
|
||||
'user_123',
|
||||
'Testing',
|
||||
'127.0.0.1',
|
||||
'Test Agent',
|
||||
);
|
||||
|
||||
expect(mockAuditLogRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'user_123',
|
||||
action: 'account_deletion_requested',
|
||||
ipAddress: '127.0.0.1',
|
||||
}),
|
||||
);
|
||||
expect(mockAuditLogRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelAccountDeletion', () => {
|
||||
it('should cancel pending deletion request', async () => {
|
||||
const mockDeletionRequest = {
|
||||
id: 'del_123',
|
||||
userId: 'user_123',
|
||||
status: 'pending',
|
||||
requestedAt: new Date(),
|
||||
scheduledDeletionAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
|
||||
mockDeletionRequestRepository.findOne.mockResolvedValue(
|
||||
mockDeletionRequest,
|
||||
);
|
||||
mockDeletionRequestRepository.save.mockResolvedValue({
|
||||
...mockDeletionRequest,
|
||||
status: 'cancelled',
|
||||
cancelledAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.cancelAccountDeletion(
|
||||
'user_123',
|
||||
'Changed my mind',
|
||||
);
|
||||
|
||||
expect(result.status).toBe('cancelled');
|
||||
expect(result.cancelledAt).toBeDefined();
|
||||
expect(mockDeletionRequestRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'cancelled',
|
||||
cancellationReason: 'Changed my mind',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when no pending request exists', async () => {
|
||||
mockDeletionRequestRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.cancelAccountDeletion('user_123', 'Changed my mind'),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPendingDeletionRequest', () => {
|
||||
it('should return pending deletion request', async () => {
|
||||
const mockDeletionRequest = {
|
||||
id: 'del_123',
|
||||
userId: 'user_123',
|
||||
status: 'pending',
|
||||
requestedAt: new Date(),
|
||||
scheduledDeletionAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
reason: 'Testing',
|
||||
};
|
||||
|
||||
mockDeletionRequestRepository.findOne.mockResolvedValue(
|
||||
mockDeletionRequest,
|
||||
);
|
||||
|
||||
const result = await service.getPendingDeletionRequest('user_123');
|
||||
|
||||
expect(result).toEqual(mockDeletionRequest);
|
||||
expect(mockDeletionRequestRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { userId: 'user_123', status: 'pending' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null when no pending request exists', async () => {
|
||||
mockDeletionRequestRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getPendingDeletionRequest('user_123');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user