feat: dynamic semester data loading and automated daily update setup

This commit is contained in:
2026-09-02 22:45:39 +02:00
parent e7a87e79f3
commit 3a5633d92f
9 changed files with 620 additions and 150 deletions
+163
View File
@@ -0,0 +1,163 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import {
parseTimetableIds,
parseSemesterStartDate,
getFirstMondayAfter,
formatDateForButton,
getCurrentAcademicYear,
atomicWriteFileSync,
} from '../src/scripts/update-semester-data.js';
const execFileAsync = promisify(execFile);
test('Academic Year calculation', () => {
const year = getCurrentAcademicYear();
assert.ok(typeof year.startYear === 'number');
assert.ok(typeof year.endYear === 'number');
assert.equal(year.endYear, year.startYear + 1);
});
test('Live HTML parsing from dm.unipi.it', async t => {
await t.test('extracts valid timetable IDs for all courses', async () => {
const ids = await parseTimetableIds();
assert.ok(ids, 'Timetable IDs should not be null');
const expectedKeys = ['anno-1', 'anno-2', 'anno-3', 'magistrale'];
for (const key of expectedKeys) {
assert.ok(ids[key], `Missing timetable ID for ${key}`);
assert.match(
ids[key],
/^[a-f0-9]{24}$/,
`Timetable ID for ${key} must be a 24-char hex string: ${ids[key]}`
);
}
});
await t.test('extracts valid semester start date from calendar page', async () => {
const startDateStr = await parseSemesterStartDate();
assert.ok(startDateStr, 'Semester start date string should not be null');
assert.match(
startDateStr,
/^\d{1,2}\s+[a-zA-Z]+\s+\d{4}$/,
`Date should match Italian format, got: ${startDateStr}`
);
const firstMonday = getFirstMondayAfter(startDateStr);
assert.ok(firstMonday instanceof Date);
assert.equal(firstMonday.getUTCDay(), 1, 'First Monday must be day 1 (Monday)');
const buttonText = formatDateForButton(firstMonday);
assert.match(buttonText, /^\d{1,2}\s+[a-zA-Z]+$/);
});
});
test('Cineca API verifies live parsed timetable IDs', async () => {
const ids = await parseTimetableIds();
assert.ok(ids, 'Need valid IDs to test Cineca API');
// Test the first year ID against Cineca API
const testId = ids['anno-1'];
const now = new Date();
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const res = await fetch('https://apache.prod.up.cineca.it/api/Impegni/getImpegniCalendarioPubblico', {
method: 'POST',
headers: {
'content-type': 'application/json;charset=UTF-8',
},
body: JSON.stringify({
mostraImpegniAnnullati: true,
mostraIndisponibilitaTotali: false,
linkCalendarioId: testId,
clienteId: '628de8b9b63679f193b87046',
pianificazioneTemplate: false,
dataInizio: now.toISOString(),
dataFine: nextWeek.toISOString(),
}),
});
assert.equal(res.status, 200, `Cineca API should respond with HTTP 200 for ID ${testId}`);
const data = await res.json();
assert.ok(Array.isArray(data), 'Cineca response should be a JSON array of events');
});
test('Atomic file write safety and schema', async () => {
const tmpPath = path.resolve(process.cwd(), 'tests/scratch-semester-test.json');
try {
const sampleData = {
timetableIds: {
'anno-1': '687e66d62d13e6001edd366d',
'anno-2': '687e681301ee9300198320e6',
'anno-3': '687e68bb01ee9300198320eb',
'magistrale': '687e6a9f96ea080019d439d5',
},
semesterStartDateStr: '23 settembre 2026',
firstMondayDate: '2026-09-28T00:00:00.000Z',
buttonText: 'Vai al 28 settembre! 🚀',
lastUpdated: new Date().toISOString(),
academicYear: { startYear: 2026, endYear: 2027 },
};
atomicWriteFileSync(tmpPath, JSON.stringify(sampleData, null, 2));
assert.ok(fs.existsSync(tmpPath), 'Output file should exist');
const readBack = JSON.parse(fs.readFileSync(tmpPath, 'utf8'));
assert.deepEqual(readBack.timetableIds, sampleData.timetableIds);
assert.equal(readBack.buttonText, sampleData.buttonText);
} finally {
if (fs.existsSync(tmpPath)) {
fs.unlinkSync(tmpPath);
}
}
});
test('CLI command execution', async t => {
await t.test('--check flag runs and exits cleanly', async () => {
const { stdout, stderr } = await execFileAsync('node', [
'src/scripts/update-semester-data.js',
'--check',
]);
assert.match(stdout, /Semester data is up to date/);
assert.equal(stderr, '');
});
await t.test('--output flag writes to custom path', async () => {
const tmpPath = path.resolve(process.cwd(), 'tests/cli-output-test.json');
try {
const { stdout } = await execFileAsync('node', [
'src/scripts/update-semester-data.js',
'--output',
tmpPath,
'--quiet',
]);
assert.ok(fs.existsSync(tmpPath), 'Target output file was created');
const data = JSON.parse(fs.readFileSync(tmpPath, 'utf8'));
assert.ok(data.timetableIds['anno-1']);
} finally {
if (fs.existsSync(tmpPath)) {
fs.unlinkSync(tmpPath);
}
}
});
});
test('Dev server serves semester-data.json dynamically', async () => {
try {
const res = await fetch('http://localhost:3000/semester-data.json');
if (res.ok) {
const data = await res.json();
assert.ok(data.timetableIds);
assert.ok(data.timetableIds['anno-1']);
assert.ok(data.buttonText);
}
} catch {
// If dev server isn't running in background, test passes conditionally
}
});