/** * Property-Based Tests: Dashboard, Department, Level, AuditLog * Feature: degree-management-refactor * Uses: fast-check + mongodb-memory-server + jest * Properties: 19, 20, 21, 22, 23, 24 */ const mongoose = require('mongoose'); const { MongoMemoryServer } = require('mongodb-memory-server'); const fc = require('fast-check'); // Models const Degree = require('../models/degree'); const Department = require('../models/department'); const Level = require('../models/level'); const AuditLog = require('../models/auditLog'); // Audit const writeAuditLog = require('../audit/writeAuditLog'); const AUDIT_ACTIONS = require('../constants/auditAction'); let mongod; jest.setTimeout(120000); // ─── Setup / Teardown ──────────────────────────────────────────────────────── beforeAll(async () => { mongod = await MongoMemoryServer.create(); const uri = mongod.getUri(); await mongoose.connect(uri); }); afterAll(async () => { await mongoose.disconnect(); await mongod.stop(); }); afterEach(async () => { await Degree.deleteMany({}); await Department.deleteMany({}); await Level.deleteMany({}); await AuditLog.deleteMany({}); }); // ─── Helpers ───────────────────────────────────────────────────────────────── let _counter = 0; function uid() { return `${Date.now()}_${++_counter}_${Math.random().toString(36).slice(2)}`; } async function createDept(nameSuffix) { const id = nameSuffix || uid(); return Department.create({ name: `dept_${id}`, slug: `dept-${id}` }); } async function createLevel(typeSuffix) { const id = typeSuffix || uid(); return Level.create({ type: `level_${id}` }); } function makeDegreeData(dept, level, overrides = {}) { const id = uid(); return { qualification_number: `QN-${id}`, student_name: `Student ${id}`, program_name: `Program ${id}`, type: 'qualification', department: dept._id, level: level._id, issued_date: new Date('2024-01-01'), status: 'active', ...overrides, }; } // Minimal mock req for writeAuditLog function mockReq() { return { session: { user: { id: null } }, headers: { 'x-forwarded-for': '127.0.0.1' }, ip: '127.0.0.1', }; } // ─── Property 19: Dashboard thống kê chính xác ─────────────────────────────── // Feature: degree-management-refactor, Property 19: Dashboard thống kê chính xác test('Property 19: dashboard counts match seeded degree data', async () => { // Validates: Requirements 7.1, 7.2, 7.3 await fc.assert( fc.asyncProperty( fc.integer({ min: 1, max: 10 }), fc.integer({ min: 0, max: 10 }), async (qualCount, certCount) => { await Degree.deleteMany({}); await Department.deleteMany({}); await Level.deleteMany({}); const dept = await createDept(); const lvl = await createLevel(); // Seed qualification degrees (all active) for (let i = 0; i < qualCount; i++) { await Degree.create(makeDegreeData(dept, lvl, { type: 'qualification', status: 'active' })); } // Seed certification degrees (half active, half revoked) const certActive = Math.floor(certCount / 2); const certRevoked = certCount - certActive; for (let i = 0; i < certActive; i++) { const id = uid(); await Degree.create(makeDegreeData(dept, lvl, { type: 'certification', certification_number: `CN-${id}`, status: 'active', })); } for (let i = 0; i < certRevoked; i++) { const id = uid(); await Degree.create(makeDegreeData(dept, lvl, { type: 'certification', certification_number: `CN-${id}`, status: 'revoked', })); } const expectedTotal = qualCount + certCount; const expectedQual = qualCount; const expectedCert = certCount; const expectedActive = qualCount + certActive; const expectedRevoked = certRevoked; // Run same queries as dashboardController.getDashboard const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const [total, qualificationCount, certificationCount, activeCount, revokedCount, recentCount] = await Promise.all([ Degree.countDocuments(), Degree.countDocuments({ type: 'qualification' }), Degree.countDocuments({ type: 'certification' }), Degree.countDocuments({ status: 'active' }), Degree.countDocuments({ status: 'revoked' }), Degree.countDocuments({ createdAt: { $gte: thirtyDaysAgo } }), ]); expect(total).toBe(expectedTotal); expect(qualificationCount).toBe(expectedQual); expect(certificationCount).toBe(expectedCert); expect(activeCount).toBe(expectedActive); expect(revokedCount).toBe(expectedRevoked); // All seeded degrees are recent (just created) expect(recentCount).toBe(expectedTotal); } ), { numRuns: 100 } ); }, 120000); // ─── Property 20: Tự động sinh slug cho Department ─────────────────────────── // Feature: degree-management-refactor, Property 20: Tự động sinh slug cho Department test('Property 20: slug generated from department name is lowercase, spaces replaced with hyphens, no special chars', async () => { // Validates: Requirements 8.2 await fc.assert( fc.asyncProperty( // Generate names with letters, spaces, digits — avoid empty after trim fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9 ]{0,28}[a-zA-Z0-9]$/).filter(s => s.trim().length > 0), async (name) => { // Apply same slug logic as departmentController.create const slug = name.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); // Verify slug properties expect(slug).toBe(slug.toLowerCase()); expect(slug).not.toMatch(/\s/); expect(slug).not.toMatch(/[^a-z0-9-]/); if (name.trim().includes(' ')) { expect(slug).toContain('-'); } } ), { numRuns: 100 } ); }, 60000); // ─── Property 21: Level yêu cầu trường type ────────────────────────────────── // Feature: degree-management-refactor, Property 21: Level yêu cầu trường type test('Property 21: creating Level without type field throws ValidationError', async () => { // Validates: Requirements 8.5 await fc.assert( fc.asyncProperty( fc.constant(null), async () => { let threw = false; try { const doc = new Level({}); await doc.save(); } catch (err) { threw = true; expect( err.name === 'ValidationError' || (err.message && err.message.toLowerCase().includes('type')) ).toBe(true); } expect(threw).toBe(true); } ), { numRuns: 100 } ); }, 60000); // ─── Property 22: Referential integrity khi xóa Department/Level ───────────── // Feature: degree-management-refactor, Property 22: Referential integrity khi xóa Department/Level test('Property 22: deleting Department or Level referenced by a Degree is blocked', async () => { // Validates: Requirements 8.6 await fc.assert( fc.asyncProperty( fc.boolean(), // true = test Department, false = test Level async (testDepartment) => { const dept = await createDept(); const lvl = await createLevel(); // Create a degree referencing both await Degree.create(makeDegreeData(dept, lvl)); if (testDepartment) { // Simulate departmentController.destroy logic const count = await Degree.countDocuments({ department: dept._id }); expect(count).toBeGreaterThan(0); // Deletion should be blocked (count > 0 means we would not delete) const wouldDelete = count === 0; expect(wouldDelete).toBe(false); } else { // Simulate levelController.destroy logic const count = await Degree.countDocuments({ level: lvl._id }); expect(count).toBeGreaterThan(0); const wouldDelete = count === 0; expect(wouldDelete).toBe(false); } } ), { numRuns: 100 } ); }, 120000); // ─── Property 23: Audit log được ghi sau mỗi CRUD operation ────────────────── // Feature: degree-management-refactor, Property 23: Audit log được ghi sau mỗi CRUD operation test('Property 23: writeAuditLog creates AuditLog with correct action, model, documentId', async () => { // Validates: Requirements 9.1 await fc.assert( fc.asyncProperty( fc.constantFrom( AUDIT_ACTIONS.CREATE_DEGREE, AUDIT_ACTIONS.UPDATE_DEGREE, AUDIT_ACTIONS.DELETE_DEGREE ), async (action) => { const dept = await createDept(); const lvl = await createLevel(); const degree = await Degree.create(makeDegreeData(dept, lvl)); await writeAuditLog({ model: 'Degree', documentId: degree._id, action, before: null, after: degree.toObject(), changes: [], req: mockReq(), }); const log = await AuditLog.findOne({ documentId: degree._id, action }); expect(log).not.toBeNull(); expect(log.model).toBe('Degree'); expect(log.action).toBe(action); expect(log.documentId.toString()).toBe(degree._id.toString()); // cleanup await AuditLog.deleteMany({ documentId: degree._id }); await Degree.deleteOne({ _id: degree._id }); } ), { numRuns: 100 } ); }, 120000); // ─── Property 24: Audit log hiển thị theo thứ tự thời gian giảm dần ────────── // Feature: degree-management-refactor, Property 24: Audit log hiển thị theo thứ tự thời gian giảm dần test('Property 24: AuditLog.find().sort({ createdAt: -1 }) returns logs in descending order', async () => { // Validates: Requirements 9.2 await fc.assert( fc.asyncProperty( fc.integer({ min: 2, max: 8 }), async (n) => { await AuditLog.deleteMany({}); const dept = await createDept(); const lvl = await createLevel(); const degree = await Degree.create(makeDegreeData(dept, lvl)); // Create n audit logs with distinct timestamps (1 second apart) const base = Date.now() - n * 1000; for (let i = 0; i < n; i++) { await AuditLog.create({ model: 'Degree', documentId: degree._id, action: AUDIT_ACTIONS.CREATE_DEGREE, before: null, after: null, changes: [], ipAddress: '127.0.0.1', userAgent: '', performedBy: null, createdAt: new Date(base + i * 1000), }); } const logs = await AuditLog.find().sort({ createdAt: -1 }); expect(logs.length).toBe(n); for (let i = 0; i < logs.length - 1; i++) { expect(logs[i].createdAt.getTime()).toBeGreaterThanOrEqual( logs[i + 1].createdAt.getTime() ); } // cleanup await AuditLog.deleteMany({}); await Degree.deleteOne({ _id: degree._id }); } ), { numRuns: 100 } ); }, 120000);