/** * 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);