forked from UKSOURCE/cms.hailearning.edu.vn
339 lines
12 KiB
JavaScript
339 lines
12 KiB
JavaScript
/**
|
|
* 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);
|