feat(testing): Implement testing foundation with strategy and first unit tests
Some checks failed
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled

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:
2025-10-02 18:54:17 +00:00
parent 3335255710
commit b2f3551ccd
4 changed files with 939 additions and 0 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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();
});
});
});