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

526
TESTING_STRATEGY.md Normal file
View File

@@ -0,0 +1,526 @@
# Testing Strategy - Maternal App
**Target Coverage:** 80%+ across all layers
**Testing Pyramid:** Unit (70%) → Integration (20%) → E2E (10%)
---
## 1. Backend Testing (NestJS)
### 1.1 Unit Tests (Target: 70% of tests)
**Tools:** Jest, @nestjs/testing
**What to Test:**
- **Services** (Business Logic)
- ComplianceService (data export, account deletion)
- AuthService (registration with COPPA validation, login, token management)
- ChildrenService (CRUD operations)
- TrackingService (activity creation, daily summary)
- AIService (chat, conversation memory)
- VoiceService (intent classification, entity extraction)
- EmbeddingsService (vector search, semantic memory)
- **Guards**
- JwtAuthGuard
- Public decorator
- **Pipes**
- ValidationPipe (DTO validation)
- **Utilities**
- Date calculations
- Age verification (COPPA compliance)
- Token generation
**Example Test Structure:**
```typescript
describe('ComplianceService', () => {
let service: ComplianceService;
let userRepository: Repository<User>;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
ComplianceService,
{ provide: getRepositoryToken(User), useClass: Repository },
],
}).compile();
service = module.get<ComplianceService>(ComplianceService);
});
it('should export user data with all entities', async () => {
// Test implementation
});
it('should schedule account deletion with 30-day grace period', async () => {
// Test implementation
});
});
```
### 1.2 Integration Tests (Target: 20% of tests)
**Tools:** Jest, Supertest, @nestjs/testing
**What to Test:**
- **API Endpoints** (Controller + Service + Database)
- POST /api/v1/compliance/data-export
- POST /api/v1/compliance/request-deletion
- POST /api/v1/auth/register (with COPPA validation)
- POST /api/v1/auth/login
- POST /api/v1/activities (tracking)
- POST /api/v1/ai/chat
- **Authentication Flows**
- Register → Login → Protected Route
- Register with COPPA (age 13-17)
- Register blocked (age < 13)
- **Database Operations**
- CRUD operations with real database
- Transaction rollbacks
- Cascade deletions
**Example Test Structure:**
```typescript
describe('ComplianceController (e2e)', () => {
let app: INestApplication;
let authToken: string;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/api/v1/compliance/data-export (GET)', () => {
return request(app.getHttpServer())
.get('/api/v1/compliance/data-export')
.set('Authorization', `Bearer ${authToken}`)
.expect(200)
.expect((res) => {
expect(res.body.data).toHaveProperty('user');
expect(res.body.data).toHaveProperty('children');
expect(res.body.data).toHaveProperty('activities');
});
});
});
```
---
## 2. Frontend Testing (Next.js + React)
### 2.1 Unit Tests (Component Testing)
**Tools:** Jest, React Testing Library, @testing-library/user-event
**What to Test:**
- **Components**
- DataExport component (button click, download trigger)
- AccountDeletion component (dialog flow, deletion request)
- Registration form (COPPA age validation, form submission)
- Settings page (profile update, compliance sections)
- Tracking forms (feeding, sleep, diaper)
- **Redux Slices**
- authSlice (login, logout, token refresh)
- childrenSlice (CRUD operations)
- activitiesSlice (create, update, delete)
- offlineSlice (sync queue, conflict resolution)
- **API Clients**
- complianceApi (all methods)
- authApi, childrenApi, trackingApi
- **Utilities**
- Date formatting
- Age calculation
- Token storage
**Example Test Structure:**
```typescript
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { DataExport } from '@/components/settings/DataExport';
import { complianceApi } from '@/lib/api/compliance';
jest.mock('@/lib/api/compliance');
describe('DataExport Component', () => {
it('should download user data when button is clicked', async () => {
const mockDownload = jest.fn();
(complianceApi.downloadUserData as jest.Mock) = mockDownload;
render(<DataExport />);
const button = screen.getByText('Download My Data');
fireEvent.click(button);
await waitFor(() => {
expect(mockDownload).toHaveBeenCalledTimes(1);
});
expect(screen.getByText('Your data has been downloaded successfully!')).toBeInTheDocument();
});
it('should show error message on download failure', async () => {
(complianceApi.downloadUserData as jest.Mock).mockRejectedValue(
new Error('Network error')
);
render(<DataExport />);
fireEvent.click(screen.getByText('Download My Data'));
await waitFor(() => {
expect(screen.getByText(/Failed to export data/)).toBeInTheDocument();
});
});
});
```
### 2.2 Integration Tests (API Interaction)
**What to Test:**
- Form submission with API calls
- Authentication flows
- Error handling and retry logic
- Optimistic updates with Redux
---
## 3. End-to-End Tests (Target: 10% of tests)
**Tools:** Playwright
**Critical User Journeys:**
### 3.1 Registration & COPPA Compliance
```typescript
test('User under 13 cannot register', async ({ page }) => {
await page.goto('/register');
await page.fill('[name="name"]', 'John Doe');
await page.fill('[name="email"]', 'john@example.com');
await page.fill('[name="dateOfBirth"]', '2015-01-01'); // 9 years old
await page.fill('[name="password"]', 'SecurePass123');
await page.fill('[name="confirmPassword"]', 'SecurePass123');
await page.check('[name="agreeToTerms"]');
await page.check('[name="agreeToPrivacy"]');
await page.click('button[type="submit"]');
await expect(page.locator('text=/Users under 13/')).toBeVisible();
});
test('User 13-17 requires parental consent', async ({ page }) => {
await page.goto('/register');
await page.fill('[name="dateOfBirth"]', '2010-01-01'); // 14 years old
// Parental email field should appear
await expect(page.locator('[name="parentalEmail"]')).toBeVisible();
await expect(page.locator('text=/parental consent/')).toBeVisible();
});
```
### 3.2 Account Deletion Flow
```typescript
test('User can request account deletion and cancel it', async ({ page }) => {
// Login
await login(page, 'user@example.com', 'password');
// Navigate to settings
await page.goto('/settings');
// Request deletion
await page.click('text=Delete My Account');
await page.fill('[name="reason"]', 'Testing deletion flow');
await page.click('button:has-text("Delete Account")');
// Verify deletion scheduled
await expect(page.locator('text=/Account deletion scheduled/')).toBeVisible();
// Cancel deletion
await page.click('text=Cancel Deletion Request');
await page.click('button:has-text("Cancel Deletion")');
// Verify cancellation
await expect(page.locator('text=/cancelled successfully/')).toBeVisible();
});
```
### 3.3 Data Export Flow
```typescript
test('User can export their data', async ({ page }) => {
await login(page, 'user@example.com', 'password');
await page.goto('/settings');
const [download] = await Promise.all([
page.waitForEvent('download'),
page.click('text=Download My Data')
]);
expect(download.suggestedFilename()).toMatch(/maternal-app-data-export.*\.json/);
});
```
### 3.4 Activity Tracking Flow
```typescript
test('User can track feeding activity', async ({ page }) => {
await login(page, 'user@example.com', 'password');
await page.goto('/track/feeding');
await page.selectOption('[name="childId"]', { index: 0 });
await page.fill('[name="amount"]', '120');
await page.selectOption('[name="unit"]', 'ml');
await page.click('button:has-text("Save")');
await expect(page.locator('text=/Activity saved/')).toBeVisible();
// Verify on dashboard
await page.goto('/');
await expect(page.locator('text=/Feeding/')).toBeVisible();
});
```
---
## 4. Code Coverage Goals
### 4.1 Overall Targets
- **Total Coverage:** 80%+
- **Statements:** 80%+
- **Branches:** 75%+
- **Functions:** 80%+
- **Lines:** 80%+
### 4.2 Module-Specific Targets
**Backend:**
- Auth Module: 90%+ (critical security)
- Compliance Module: 95%+ (legal requirement)
- Tracking Module: 85%+
- AI Module: 80%+
- Voice Module: 80%+
**Frontend:**
- Compliance Components: 95%+
- Auth Components: 90%+
- Tracking Components: 85%+
- API Clients: 90%+
- Redux Slices: 85%+
---
## 5. CI/CD Integration
### 5.1 GitHub Actions Workflow
**Triggers:**
- Push to main branch
- Pull request to main
- Scheduled nightly runs
**Steps:**
1. Checkout code
2. Setup Node.js (v20)
3. Install dependencies
4. Run linter (ESLint)
5. Run backend unit tests
6. Run backend integration tests
7. Run frontend tests
8. Run E2E tests
9. Generate coverage reports
10. Upload coverage to Codecov
11. Fail PR if coverage drops below 80%
**Example `.github/workflows/test.yml`:**
```yaml
name: Test Suite
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '20'
- run: npm ci
- run: npm test -- --coverage
- uses: codecov/codecov-action@v3
frontend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npm test -- --coverage
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npx playwright install --with-deps
- run: npm run test:e2e
```
---
## 6. Test Data Management
### 6.1 Test Database
- Use separate test database
- Seed with test fixtures
- Clean between tests
### 6.2 Test Fixtures
- Create factory functions for test data
- Predefined users, children, activities
- COPPA-compliant test scenarios (various ages)
**Example:**
```typescript
export const testUsers = {
adult: {
name: 'Adult User',
email: 'adult@example.com',
dateOfBirth: '1990-01-01',
},
minor14: {
name: 'Teen User',
email: 'teen@example.com',
dateOfBirth: '2010-01-01',
parentalEmail: 'parent@example.com',
coppaConsentGiven: true,
},
child12: {
name: 'Child User',
email: 'child@example.com',
dateOfBirth: '2012-01-01', // Should be blocked
},
};
```
---
## 7. Testing Best Practices
### 7.1 General Principles
- **AAA Pattern:** Arrange, Act, Assert
- **DRY:** Don't Repeat Yourself (use factories and helpers)
- **Fast:** Keep unit tests under 100ms
- **Isolated:** No dependencies between tests
- **Deterministic:** Same input = same output
### 7.2 Mocking Strategy
- Mock external services (Azure OpenAI, Mailgun, MinIO)
- Mock time-dependent functions
- Use in-memory database for unit tests
- Real database for integration tests
### 7.3 Naming Conventions
```typescript
describe('ServiceName', () => {
describe('methodName', () => {
it('should do something when condition', () => {
// Test
});
it('should throw error when invalid input', () => {
// Test
});
});
});
```
---
## 8. Continuous Improvement
### 8.1 Metrics to Track
- Code coverage percentage
- Test execution time
- Flaky test rate
- Bug escape rate
### 8.2 Review Process
- All PRs must have tests
- Coverage must not decrease
- CI must pass before merge
---
## 9. Implementation Timeline
**Week 1:**
- ✅ Setup Jest for backend and frontend
- ✅ Create first unit tests (Compliance, Auth)
- ✅ Setup test database
**Week 2:**
- ✅ Create integration tests for critical endpoints
- ✅ Setup Playwright for E2E
- ✅ Create E2E tests for COPPA flows
**Week 3:**
- ✅ Expand coverage to 50%+
- ✅ Setup CI/CD pipeline
- ✅ Configure coverage reporting
**Week 4:**
- ✅ Reach 80%+ coverage
- ✅ Optimize test performance
- ✅ Documentation and training
---
## 10. Quick Start Commands
```bash
# Backend
cd maternal-app-backend
npm test # Run all tests
npm test -- --coverage # With coverage
npm test -- --watch # Watch mode
npm run test:e2e # Integration tests
# Frontend
cd maternal-web
npm test # Run all tests
npm test -- --coverage # With coverage
npm test -- --watch # Watch mode
npm run test:e2e # E2E with Playwright
# Coverage Reports
npm test -- --coverage --coverageReporters=html
# Open coverage/index.html in browser
```
---
## Status: IN PROGRESS
**Current Coverage:** 0%
**Target Coverage:** 80%+
**Estimated Completion:** 3-4 weeks

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