first commit

This commit is contained in:
2026-04-11 14:08:27 +07:00
parent e86e5d2c46
commit 6b7655aa16
389 changed files with 5387 additions and 60861 deletions

179
tests/api.property.test.js Normal file
View File

@@ -0,0 +1,179 @@
/**
* Property-Based Tests for API endpoints
* Feature: degree-management-refactor
* Uses: fast-check + mongodb-memory-server + jest + supertest
*/
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const fc = require('fast-check');
const express = require('express');
const request = require('supertest');
// Models
const Degree = require('../models/degree');
const Department = require('../models/department');
const Level = require('../models/level');
// Routes
const indexRoutes = require('../routes/index');
let mongod;
let app;
// Increase global timeout for slow MongoDB startup and PBT runs
jest.setTimeout(120000);
// ─── Setup / Teardown ────────────────────────────────────────────────────────
beforeAll(async () => {
// Set test API key before anything else
process.env.API_KEY = 'test-api-key-12345';
mongod = await MongoMemoryServer.create();
const uri = mongod.getUri();
await mongoose.connect(uri);
// Create a minimal Express app that mounts routes/index.js only
app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use('/', indexRoutes);
});
afterAll(async () => {
await mongoose.disconnect();
await mongod.stop();
});
afterEach(async () => {
await Degree.deleteMany({});
await Department.deleteMany({});
await Level.deleteMany({});
});
// ─── Seed Helpers ────────────────────────────────────────────────────────────
let _counter = 0;
function uid() {
return `${Date.now()}_${++_counter}_${Math.random().toString(36).slice(2)}`;
}
async function createDept() {
const id = uid();
return Department.create({ name: `dept_${id}`, slug: `dept-${id}` });
}
async function createLevel() {
const id = 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,
};
}
// ─── Property 15: API lookup trả về đúng thông tin Degree ───────────────────
// Feature: degree-management-refactor, Property 15: API lookup trả về đúng thông tin Degree
test('Property 15: GET /api/degree/:qualificationNumber returns correct Degree fields', async () => {
// Validates: Requirements 5.1, 5.2
await fc.assert(
fc.asyncProperty(
fc.record({
student_name: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim() === s && s.trim().length > 0),
program_name: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim() === s && s.trim().length > 0),
}),
async ({ student_name, program_name }) => {
const dept = await createDept();
const lvl = await createLevel();
const data = makeDegreeData(dept, lvl, { student_name, program_name });
const saved = await Degree.create(data);
const res = await request(app)
.get(`/api/degree/${saved.qualification_number}`)
.set('x-api-key', process.env.API_KEY);
expect(res.status).toBe(200);
expect(res.body.student_name).toBe(student_name);
expect(res.body.program_name).toBe(program_name);
expect(res.body.type).toBe(data.type);
expect(res.body.status).toBe(data.status);
expect(res.body.issued_date).toBeDefined();
}
),
{ numRuns: 100 }
);
}, 120000);
// ─── Property 16: API không có API key hợp lệ trả về 401 ────────────────────
// Feature: degree-management-refactor, Property 16: API không có API key hợp lệ trả về 401
test('Property 16: requests without valid x-api-key return 401', async () => {
// Validates: Requirements 5.3, 5.6
await fc.assert(
fc.asyncProperty(
fc.oneof(
fc.constant(null), // no header at all
fc.string({ minLength: 1, maxLength: 40 }) // random invalid key
.filter(s => s !== process.env.API_KEY)
),
async (invalidKey) => {
const dept = await createDept();
const lvl = await createLevel();
const data = makeDegreeData(dept, lvl);
await Degree.create(data);
let req = request(app).get(`/api/degree/${data.qualification_number}`);
if (invalidKey !== null) {
req = req.set('x-api-key', invalidKey);
}
const res = await req;
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'Unauthorized - Invalid API key' });
}
),
{ numRuns: 100 }
);
}, 120000);
// ─── Property 17: Degree bị thu hồi vẫn trả về với status revoked ───────────
// Feature: degree-management-refactor, Property 17: Degree bị thu hồi vẫn trả về với status revoked
test('Property 17: revoked Degree is returned by API with status "revoked" (not 404)', async () => {
// Validates: Requirements 5.5
await fc.assert(
fc.asyncProperty(
fc.constant(null), // no extra input needed
async () => {
const dept = await createDept();
const lvl = await createLevel();
const data = makeDegreeData(dept, lvl, { status: 'revoked' });
const saved = await Degree.create(data);
const res = await request(app)
.get(`/api/degree/${saved.qualification_number}`)
.set('x-api-key', process.env.API_KEY);
expect(res.status).toBe(200);
expect(res.body.status).toBe('revoked');
}
),
{ numRuns: 100 }
);
}, 120000);

View File

@@ -0,0 +1,212 @@
/**
* Property-Based Tests for auth guard and routes
* Feature: degree-management-refactor
* Uses: fast-check + jest + supertest (no MongoDB needed)
*/
const fc = require('fast-check');
const express = require('express');
const session = require('express-session');
const request = require('supertest');
// Routes
const authRoutes = require('../routes/auth');
const adminRoutes = require('../routes/admin');
const indexRoutes = require('../routes/index');
// Middleware
const { ensureAuthenticated } = require('../middleware/auth');
jest.setTimeout(120000);
// ─── Minimal App Factory ─────────────────────────────────────────────────────
/**
* Build a minimal Express app that:
* - Uses in-memory session (no MongoDB store)
* - Mounts /auth, /admin, / routes
* - Has a 404 handler
*/
function buildApp() {
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// In-memory session (no MongoStore needed for auth/route tests)
app.use(
session({
secret: 'test-secret',
resave: false,
saveUninitialized: false,
})
);
// Stub flash so auth routes don't crash
app.use((req, res, next) => {
req.flash = () => {};
next();
});
// Stub res.locals used by views/middleware
app.use((req, res, next) => {
res.locals.user = null;
res.locals.currentPath = req.path;
res.locals.frontendUrl = '';
next();
});
// Mount routes
app.use('/auth', authRoutes);
app.use('/admin', adminRoutes);
app.use('/', indexRoutes);
// 404 handler (mirrors server.js)
app.use((req, res) => {
res.status(404);
if (req.accepts('json')) return res.json({ error: 'Not found' });
res.type('txt').send('Not found');
});
return app;
}
// ─── Known route prefixes (whitelist) ────────────────────────────────────────
const KNOWN_PREFIXES = ['/api/degree/', '/api/certificate/', '/admin/', '/auth/'];
function isKnownRoute(path) {
return KNOWN_PREFIXES.some((prefix) => path.startsWith(prefix));
}
// ─── Property 1: Route ngoài phạm vi trả về 404 ──────────────────────────────
// Feature: degree-management-refactor, Property 1: Route ngoài phạm vi trả về 404
test('Property 1: unknown routes return 404', async () => {
// Validates: Requirements 1.3
const app = buildApp();
// Arbitrary: random path segments that don't start with known prefixes
const unknownPathArb = fc
.array(fc.stringMatching(/^[a-z0-9_-]{1,10}$/), { minLength: 1, maxLength: 4 })
.map((parts) => '/' + parts.join('/'))
.filter((path) => !isKnownRoute(path));
await fc.assert(
fc.asyncProperty(unknownPathArb, async (path) => {
const res = await request(app).get(path).set('Accept', 'application/json');
expect(res.status).toBe(404);
}),
{ numRuns: 100 }
);
}, 120000);
// ─── Property 11: Authentication guard trên admin routes ─────────────────────
// Feature: degree-management-refactor, Property 11: Authentication guard trên admin routes
test('Property 11: unauthenticated requests to /admin/* are redirected to /auth/login', async () => {
// Validates: Requirements 3.11
const app = buildApp();
// Known admin sub-paths to test
const adminSubPathArb = fc
.constantFrom(
'/admin/dashboard',
'/admin/degree',
'/admin/degree/create',
'/admin/department',
'/admin/level',
'/admin/audit-logs'
);
await fc.assert(
fc.asyncProperty(adminSubPathArb, async (path) => {
// No session cookie → not authenticated
const res = await request(app).get(path);
expect(res.status).toBe(302);
expect(res.headers.location).toBe('/auth/login');
}),
{ numRuns: 100 }
);
}, 120000);
// ─── Property 18: Login, logout, request /admin/* → redirect ─────────────────
// Feature: degree-management-refactor, Property 18: Login, logout, request /admin/* → redirect
test('Property 18: after logout, requests to /admin/* redirect to /auth/login', async () => {
// Validates: Requirements 6.5
const app = buildApp();
const adminSubPathArb = fc.constantFrom(
'/admin/dashboard',
'/admin/degree',
'/admin/department',
'/admin/level',
'/admin/audit-logs'
);
await fc.assert(
fc.asyncProperty(adminSubPathArb, async (path) => {
// Step 1: Simulate an authenticated session by injecting isAuthenticated
// We do this by adding a one-time setup route that sets the session flag
const testApp = express();
testApp.use(express.json());
testApp.use(express.urlencoded({ extended: true }));
testApp.use(
session({
secret: 'test-secret',
resave: false,
saveUninitialized: false,
})
);
testApp.use((req, res, next) => {
req.flash = () => {};
next();
});
testApp.use((req, res, next) => {
res.locals.user = null;
res.locals.currentPath = req.path;
res.locals.frontendUrl = '';
next();
});
// Helper route: sets isAuthenticated = true in session
testApp.get('/__test_login', (req, res) => {
req.session.isAuthenticated = true;
req.session.user = { id: 'test', username: 'testuser' };
res.json({ ok: true });
});
// Helper route: destroys session (simulates logout)
testApp.get('/__test_logout', (req, res) => {
req.session.destroy(() => res.json({ ok: true }));
});
testApp.use('/auth', authRoutes);
testApp.use('/admin', adminRoutes);
testApp.use('/', indexRoutes);
testApp.use((req, res) => {
res.status(404);
if (req.accepts('json')) return res.json({ error: 'Not found' });
res.type('txt').send('Not found');
});
const agent = request.agent(testApp);
// Step 2: Login (set session)
await agent.get('/__test_login');
// Step 3: Logout (destroy session)
await agent.get('/__test_logout');
// Step 4: Request admin route — should redirect to /auth/login
const res = await agent.get(path);
expect(res.status).toBe(302);
expect(res.headers.location).toBe('/auth/login');
}),
{ numRuns: 100 }
);
}, 120000);

View File

@@ -0,0 +1,338 @@
/**
* 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);

View File

@@ -0,0 +1,325 @@
/**
* Property-Based Tests for Degree model
* Feature: degree-management-refactor
* Uses: fast-check + mongodb-memory-server + jest
*/
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');
let mongod;
// Increase global timeout for slow MongoDB startup and PBT runs
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({});
});
// ─── Seed Helpers ────────────────────────────────────────────────────────────
let _counter = 0;
function uid() {
return `${Date.now()}_${++_counter}_${Math.random().toString(36).slice(2)}`;
}
async function createDept() {
const id = uid();
return Department.create({ name: `dept_${id}`, slug: `dept-${id}` });
}
async function createLevel() {
const id = 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'),
...overrides,
};
}
// ─── Property 2: Validation các trường bắt buộc của Degree ──────────────────
// Feature: degree-management-refactor, Property 2: Validation các trường bắt buộc của Degree
test('Property 2: saving Degree without each required field throws ValidationError', async () => {
// Validates: Requirements 2.1
const requiredFields = [
'qualification_number',
'student_name',
'program_name',
'type',
'department',
'level',
'issued_date',
];
await fc.assert(
fc.asyncProperty(
fc.constantFrom(...requiredFields),
async (missingField) => {
const dept = await createDept();
const lvl = await createLevel();
const data = makeDegreeData(dept, lvl);
delete data[missingField];
let threw = false;
try {
const doc = new Degree(data);
await doc.save();
} catch (err) {
threw = true;
// Should be a ValidationError (or pre-save Error for type-related)
const isValidation =
err.name === 'ValidationError' ||
(err.message && err.message.includes('required'));
expect(isValidation).toBe(true);
}
expect(threw).toBe(true);
}
),
{ numRuns: 100 }
);
}, 60000);
// ─── Property 3: Conditional validation type-number ─────────────────────────
// Feature: degree-management-refactor, Property 3: Conditional validation type-number
test('Property 3: certification type without certification_number throws; qualification saves ok', async () => {
// Validates: Requirements 2.3, 2.4
await fc.assert(
fc.asyncProperty(
fc.boolean(), // true = certification (should fail), false = qualification (should pass)
async (isCertification) => {
const dept = await createDept();
const lvl = await createLevel();
if (isCertification) {
// certification without certification_number → must throw
const data = makeDegreeData(dept, lvl, { type: 'certification' });
// no certification_number
let threw = false;
try {
const doc = new Degree(data);
await doc.save();
} catch (err) {
threw = true;
expect(err.message).toMatch(/certification_number/i);
}
expect(threw).toBe(true);
} else {
// qualification without certification_number → must succeed
const data = makeDegreeData(dept, lvl, { type: 'qualification' });
const doc = new Degree(data);
const saved = await doc.save();
expect(saved._id).toBeDefined();
await Degree.deleteOne({ _id: saved._id });
}
}
),
{ numRuns: 100 }
);
}, 60000);
// ─── Property 4: Uniqueness của qualification_number ────────────────────────
// Feature: degree-management-refactor, Property 4: Uniqueness của qualification_number
test('Property 4: two Degrees with same qualification_number → second save throws duplicate key error', async () => {
// Validates: Requirements 2.5, 2.7
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0),
async (qn) => {
const dept = await createDept();
const lvl = await createLevel();
const data1 = makeDegreeData(dept, lvl, { qualification_number: `QN-${qn}` });
const data2 = makeDegreeData(dept, lvl, { qualification_number: `QN-${qn}` });
await Degree.create(data1);
let threw = false;
try {
await Degree.create(data2);
} catch (err) {
threw = true;
// MongoServerError code 11000 = duplicate key
expect(err.code === 11000 || err.name === 'MongoServerError').toBe(true);
}
expect(threw).toBe(true);
// cleanup
await Degree.deleteMany({ qualification_number: `QN-${qn}` });
}
),
{ numRuns: 100 }
);
}, 120000);
// ─── Property 5: Sparse uniqueness của certification_number ─────────────────
// Feature: degree-management-refactor, Property 5: Sparse uniqueness của certification_number
test('Property 5: same non-null certification_number → duplicate error; both null → both save ok', async () => {
// Validates: Requirements 2.6, 2.7
await fc.assert(
fc.asyncProperty(
fc.boolean(), // true = test duplicate, false = test sparse (both null)
async (testDuplicate) => {
const dept = await createDept();
const lvl = await createLevel();
if (testDuplicate) {
const cn = `CN-${uid()}`;
const data1 = makeDegreeData(dept, lvl, {
type: 'certification',
certification_number: cn,
});
const data2 = makeDegreeData(dept, lvl, {
type: 'certification',
certification_number: cn,
});
await Degree.create(data1);
let threw = false;
try {
await Degree.create(data2);
} catch (err) {
threw = true;
expect(err.code === 11000 || err.name === 'MongoServerError').toBe(true);
}
expect(threw).toBe(true);
await Degree.deleteMany({ certification_number: cn });
} else {
// Both without certification_number (sparse index allows multiple nulls)
const data1 = makeDegreeData(dept, lvl, { type: 'qualification' });
const data2 = makeDegreeData(dept, lvl, { type: 'qualification' });
const saved1 = await Degree.create(data1);
const saved2 = await Degree.create(data2);
expect(saved1._id).toBeDefined();
expect(saved2._id).toBeDefined();
await Degree.deleteMany({ _id: { $in: [saved1._id, saved2._id] } });
}
}
),
{ numRuns: 100 }
);
}, 120000);
// ─── Property 6: Round-trip tạo Degree ──────────────────────────────────────
// Feature: degree-management-refactor, Property 6: Round-trip tạo Degree
test('Property 6: save Degree then findById returns matching fields', async () => {
// Validates: Requirements 3.5
await fc.assert(
fc.asyncProperty(
fc.record({
student_name: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim() === s && s.trim().length > 0),
program_name: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim() === s && s.trim().length > 0),
}),
async ({ student_name, program_name }) => {
const dept = await createDept();
const lvl = await createLevel();
const data = makeDegreeData(dept, lvl, { student_name, program_name });
const saved = await Degree.create(data);
const found = await Degree.findById(saved._id);
expect(found).not.toBeNull();
expect(found.qualification_number).toBe(data.qualification_number);
expect(found.student_name).toBe(student_name);
expect(found.program_name).toBe(program_name);
expect(found.type).toBe(data.type);
expect(found.department.toString()).toBe(dept._id.toString());
expect(found.level.toString()).toBe(lvl._id.toString());
await Degree.deleteOne({ _id: saved._id });
}
),
{ numRuns: 100 }
);
}, 120000);
// ─── Property 7: Round-trip cập nhật Degree ─────────────────────────────────
// Feature: degree-management-refactor, Property 7: Round-trip cập nhật Degree
test('Property 7: update student_name then findById returns updated value', async () => {
// Validates: Requirements 3.8
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim() === s && s.trim().length > 0),
async (newName) => {
const dept = await createDept();
const lvl = await createLevel();
const data = makeDegreeData(dept, lvl);
const saved = await Degree.create(data);
await Degree.findByIdAndUpdate(saved._id, { student_name: newName });
const found = await Degree.findById(saved._id);
expect(found).not.toBeNull();
expect(found.student_name).toBe(newName);
await Degree.deleteOne({ _id: saved._id });
}
),
{ numRuns: 100 }
);
}, 120000);
// ─── Property 8: Round-trip xóa Degree ──────────────────────────────────────
// Feature: degree-management-refactor, Property 8: Round-trip xóa Degree
test('Property 8: delete Degree then findById returns null', async () => {
// Validates: Requirements 3.9
await fc.assert(
fc.asyncProperty(
fc.constant(null), // no extra input needed
async () => {
const dept = await createDept();
const lvl = await createLevel();
const data = makeDegreeData(dept, lvl);
const saved = await Degree.create(data);
await Degree.findByIdAndDelete(saved._id);
const found = await Degree.findById(saved._id);
expect(found).toBeNull();
}
),
{ numRuns: 100 }
);
}, 120000);

View File

@@ -0,0 +1,201 @@
/**
* Property-Based Tests for Degree search and filter
* Feature: degree-management-refactor
* Uses: fast-check + mongodb-memory-server + jest
*/
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');
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({});
});
// ─── Seed Helpers ────────────────────────────────────────────────────────────
let _counter = 0;
function uid() {
return `${Date.now()}_${++_counter}_${Math.random().toString(36).slice(2)}`;
}
async function createDept() {
const id = uid();
return Department.create({ name: `dept_${id}`, slug: `dept-${id}` });
}
async function createLevel() {
const id = 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,
};
}
// Arbitraries for safe alphanumeric tokens (no regex special chars)
const safeToken = fc
.string({ minLength: 2, maxLength: 10 })
.filter(s => /^[a-zA-Z0-9]+$/.test(s));
// ─── Property 9: Kết quả tìm kiếm khớp với query ────────────────────────────
// Feature: degree-management-refactor, Property 9: Kết quả tìm kiếm khớp với query
test('Property 9: every search result contains the search term in qualification_number, certification_number, or student_name', async () => {
// Validates: Requirements 3.2
await fc.assert(
fc.asyncProperty(
// Generate 1-5 degrees and a search term
fc.array(
fc.record({
type: fc.constantFrom('qualification', 'certification'),
suffix: safeToken,
}),
{ minLength: 1, maxLength: 5 }
),
safeToken,
async (degreeSpecs, searchTerm) => {
const dept = await createDept();
const lvl = await createLevel();
// Insert degrees; embed searchTerm into some of them
for (let i = 0; i < degreeSpecs.length; i++) {
const spec = degreeSpecs[i];
const overrides = { type: spec.type };
// Alternate which field gets the search term
if (i % 3 === 0) {
overrides.qualification_number = `QN-${searchTerm}-${spec.suffix}`;
} else if (i % 3 === 1) {
overrides.student_name = `${searchTerm} Student ${spec.suffix}`;
} else {
// certification type with certification_number containing term
overrides.type = 'certification';
overrides.certification_number = `CN-${searchTerm}-${spec.suffix}`;
}
if (overrides.type === 'certification' && !overrides.certification_number) {
overrides.certification_number = `CN-${uid()}`;
}
await Degree.create(makeDegreeData(dept, lvl, overrides));
}
// Run the same $or $regex query as degreeController.index
const filter = {
$or: [
{ qualification_number: { $regex: searchTerm, $options: 'i' } },
{ certification_number: { $regex: searchTerm, $options: 'i' } },
{ student_name: { $regex: searchTerm, $options: 'i' } },
],
};
const results = await Degree.find(filter);
// Every result must contain the search term in at least one of the three fields
for (const doc of results) {
const term = searchTerm.toLowerCase();
const matchesQN = doc.qualification_number
? doc.qualification_number.toLowerCase().includes(term)
: false;
const matchesCN = doc.certification_number
? doc.certification_number.toLowerCase().includes(term)
: false;
const matchesSN = doc.student_name
? doc.student_name.toLowerCase().includes(term)
: false;
expect(matchesQN || matchesCN || matchesSN).toBe(true);
}
}
),
{ numRuns: 100 }
);
}, 120000);
// ─── Property 10: Kết quả lọc khớp với filter ───────────────────────────────
// Feature: degree-management-refactor, Property 10: Kết quả lọc khớp với filter
test('Property 10: every filtered result matches the applied filter value exactly', async () => {
// Validates: Requirements 3.3
await fc.assert(
fc.asyncProperty(
// Choose which field to filter on and what value to use
fc.record({
filterField: fc.constantFrom('type', 'status'),
filterValue: fc.constantFrom('qualification', 'certification', 'active', 'revoked'),
}),
// Generate 2-6 degrees with mixed types and statuses
fc.array(
fc.record({
type: fc.constantFrom('qualification', 'certification'),
status: fc.constantFrom('active', 'revoked'),
}),
{ minLength: 2, maxLength: 6 }
),
async ({ filterField, filterValue }, degreeSpecs) => {
// Skip invalid combinations (e.g. filtering type by 'active')
const typeValues = ['qualification', 'certification'];
const statusValues = ['active', 'revoked'];
if (filterField === 'type' && !typeValues.includes(filterValue)) return;
if (filterField === 'status' && !statusValues.includes(filterValue)) return;
const dept = await createDept();
const lvl = await createLevel();
for (const spec of degreeSpecs) {
const overrides = { type: spec.type, status: spec.status };
if (spec.type === 'certification') {
overrides.certification_number = `CN-${uid()}`;
}
await Degree.create(makeDegreeData(dept, lvl, overrides));
}
// Apply filter the same way degreeController.index does
const filter = { [filterField]: filterValue };
const results = await Degree.find(filter);
// Every result must match the filter value exactly
for (const doc of results) {
expect(doc[filterField]).toBe(filterValue);
}
}
),
{ numRuns: 100 }
);
}, 120000);