- Implemented the schedule import feature with a dedicated page for importing schedules from the transport website. - Added a new QualityPage to check the quality of routes, stops, schedules, and notifications without making automatic changes. - Created CSS styles for both the ScheduleImportPage and QualityPage to enhance the user interface. - Introduced API calls for fetching schedule data and quality reports. - Added error handling and loading states for better user experience during data fetching. - Included functionality for filtering quality issues and displaying detailed reports.
163 lines
11 KiB
JavaScript
163 lines
11 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs/promises');
|
|
const path = require('node:path');
|
|
const { Pool } = require('pg');
|
|
const { ScheduleImportService } = require('../src/services/scheduleImportService');
|
|
const { getLocalDateTime } = require('../src/utils/etaTime');
|
|
const { parseSchedule, CATALOG_URL, HOME_URL } = require('../src/services/scheduleSource');
|
|
|
|
// Opt-in only. Use a disposable database; every run gets its own temporary schema.
|
|
test('PostgreSQL import lifecycle, concurrency, conflicts, rollback and news review', {
|
|
skip: !process.env.IMPORT_TEST_DATABASE_URL,
|
|
}, async () => {
|
|
const url = process.env.IMPORT_TEST_DATABASE_URL;
|
|
assert.match(new URL(url).pathname, /test/i, 'Use a dedicated test database');
|
|
const admin = new Pool({ connectionString: url });
|
|
const schema = `import_test_${Date.now()}`;
|
|
let pool;
|
|
try {
|
|
await admin.query('CREATE EXTENSION IF NOT EXISTS postgis');
|
|
await admin.query(`CREATE SCHEMA ${schema}`);
|
|
pool = new Pool({ connectionString: url, options: `-c search_path=${schema},public` });
|
|
for (const file of (await fs.readdir(path.join(__dirname, '../migrations'))).filter(f => f.endsWith('.sql')).sort()) {
|
|
await pool.query(await fs.readFile(path.join(__dirname, '../migrations', file), 'utf8'));
|
|
}
|
|
const fixture = name => fs.readFile(path.join(__dirname, 'fixtures/schedule-import', `${name}.html`), 'utf8');
|
|
let sourceHtml = await fixture('route1');
|
|
let newsHtml = await fixture('news');
|
|
const catalogHtml = await fixture('catalog');
|
|
const sourceUrl = 'https://ap2polotsk.of.by/ap2/m-1/';
|
|
const load = async url => ({ url, html: url === CATALOG_URL ? catalogHtml : url === HOME_URL ? newsHtml : sourceHtml });
|
|
const invalidations = [];
|
|
const service = new ScheduleImportService({ pool, load, invalidate: async id => invalidations.push(id) });
|
|
const source = parseSchedule(sourceHtml, sourceUrl);
|
|
sourceHtml = sourceHtml.replace('05:20', `05:20 через «${source.directions[0].stops[1]}»`)
|
|
.replace('06:30', '06:30 по пятницам').replace('06:50', '06:50 кроме пятницы')
|
|
.replace('07:30', '07:30 через «Нет в базе»');
|
|
const routeIds = [];
|
|
for (const direction of source.directions) {
|
|
const routeId = (await pool.query("INSERT INTO routes (route_number,name,type) VALUES ('1',$1,'bus') RETURNING id", [direction.stops[0] + ' → ' + direction.stops.at(-1)])).rows[0].id;
|
|
routeIds.push(routeId);
|
|
for (const [i, name] of direction.stops.entries()) {
|
|
const stopId = (await pool.query("INSERT INTO stops (name,location) VALUES ($1,ST_GeogFromText('POINT(28.77 55.48)')) RETURNING id", [name])).rows[0].id;
|
|
await pool.query('INSERT INTO route_stops (route_id,stop_id,sequence,time_offset_minutes) VALUES ($1,$2,$3,$4)', [routeId, stopId, i + 1, i * 2]);
|
|
}
|
|
}
|
|
const routeId = routeIds[0];
|
|
const validFrom = getLocalDateTime().date;
|
|
const prepare = async () => {
|
|
const inspection = await service.inspect({ routeIds, sourceUrl, userId: 1 });
|
|
return service.preview({ inspectionId: inspection.id, mappings: inspection.mappings, validFrom, userId: 1 });
|
|
};
|
|
|
|
const single = await service.inspect({ routeIds: [routeId], sourceUrl, userId: 1 });
|
|
assert.equal(single.directions.length, 1);
|
|
assert.equal(single.mappings[routeId], 'Швейная фабрика');
|
|
await assert.rejects(service.preview({ inspectionId: single.id, mappings: { ...single.mappings, [routeIds[1]]: 'Богатырская' }, validFrom, userId: 1 }), /каждое выбранное/);
|
|
await assert.rejects(service.inspect({ routeIds: [], sourceUrl, userId: 1 }), /направления/);
|
|
await assert.rejects(service.inspect({ routeIds: [routeId, routeId], sourceUrl, userId: 1 }), /направления/);
|
|
const other = (await pool.query("INSERT INTO routes (route_number,name,type) VALUES ('99','Другой маршрут','bus') RETURNING id")).rows[0].id;
|
|
await assert.rejects(service.inspect({ routeIds: [routeId, other], sourceUrl, userId: 1 }), /тот же номер/);
|
|
const first = await prepare();
|
|
assert.equal(first.hasChanges, true);
|
|
assert.deepEqual(first.plans.map(p => p.changes.map(c => c.after.length)), [[18, 14, 14], [18, 14, 14]]);
|
|
assert.deepEqual(first.plans.map(p => p.sourceStopName), ['Швейная фабрика', 'Богатырская']);
|
|
const concurrent = await Promise.all([service.apply(first.id), service.apply(first.id)]);
|
|
assert.equal(concurrent.filter(r => r.alreadyApplied).length, 1);
|
|
assert.equal(Number((await pool.query('SELECT count(*) FROM schedules')).rows[0].count), 6);
|
|
assert.deepEqual(invalidations.sort((a,b) => a-b), routeIds);
|
|
const savedTrips = (await pool.query("SELECT trips FROM schedules WHERE route_id=$1 AND day_type='weekday'", [routeId])).rows[0].trips;
|
|
assert.equal(savedTrips.find(t => t.departureTime === '05:20:00').viaStopIds.length, 1);
|
|
assert.deepEqual(savedTrips.find(t => t.departureTime === '06:30:00').daysOfWeek, [5]);
|
|
assert.deepEqual(savedTrips.find(t => t.departureTime === '06:50:00').exceptDaysOfWeek, [5]);
|
|
assert.equal(savedTrips.find(t => t.departureTime === '07:30:00').note, 'через «Нет в базе»');
|
|
assert.equal((await service.history()).length, 1);
|
|
assert.equal((await service.history())[0].directions.length, 2);
|
|
assert.equal((await prepare()).hasChanges, false);
|
|
assert.equal((await service.apply(first.id)).alreadyApplied, true);
|
|
assert.equal(Number((await pool.query("SELECT count(*) FROM sync_log WHERE table_name='schedules'")).rows[0].count), 6);
|
|
|
|
sourceHtml = sourceHtml.replace('05:20', '05:21');
|
|
const changed = await prepare();
|
|
sourceHtml = sourceHtml.replace('05:21', '05:22');
|
|
await assert.rejects(service.apply(changed.id), /на сайте изменилось/);
|
|
const stale = await prepare();
|
|
await pool.query("UPDATE schedules SET trips = jsonb_set(trips, '{0,note}', '\"Ручная правка\"'::jsonb) WHERE day_type = 'weekday'");
|
|
const beforeConflict = JSON.stringify((await pool.query('SELECT * FROM schedules ORDER BY id')).rows);
|
|
await assert.rejects(service.apply(stale.id), /после предпросмотра/);
|
|
assert.equal(JSON.stringify((await pool.query('SELECT * FROM schedules ORDER BY id')).rows), beforeConflict);
|
|
|
|
// Failure while writing the second direction must also roll back the first direction.
|
|
sourceHtml = sourceHtml.replace('05:40', '05:41');
|
|
const toFail = await prepare();
|
|
const beforeFailure = JSON.stringify((await pool.query('SELECT * FROM schedules ORDER BY id')).rows);
|
|
const failingPool = { query: (...args) => pool.query(...args), connect: async () => {
|
|
const client = await pool.connect();
|
|
let updates = 0;
|
|
return { release: () => client.release(), query: (sql, args) => {
|
|
if (sql.startsWith('UPDATE schedules SET departure_times') && ++updates === 2) throw new Error('Injected failure');
|
|
return client.query(sql, args);
|
|
} };
|
|
} };
|
|
await assert.rejects(new ScheduleImportService({ pool: failingPool, load }).apply(toFail.id), /Injected failure/);
|
|
assert.equal(JSON.stringify((await pool.query('SELECT * FROM schedules ORDER BY id')).rows), beforeFailure);
|
|
assert.equal((await service.getDraft(toFail.id)).status, 'ready');
|
|
await service.apply(toFail.id);
|
|
|
|
const reverseBefore = JSON.stringify((await pool.query('SELECT * FROM schedules WHERE route_id=$1 ORDER BY id', [routeIds[1]])).rows);
|
|
await pool.query("UPDATE schedules SET trips = jsonb_set(trips, '{0,note}', '\"Правка только прямого направления\"'::jsonb) WHERE day_type = 'weekday' AND route_id=$1", [routeId]);
|
|
const oneInspection = await service.inspect({ routeIds: [routeId], sourceUrl, userId: 1 });
|
|
const onePreview = await service.preview({ inspectionId: oneInspection.id, mappings: oneInspection.mappings, validFrom, userId: 1 });
|
|
assert.equal(onePreview.plans.length, 1);
|
|
assert.equal(onePreview.hasChanges, true);
|
|
await service.apply(onePreview.id);
|
|
assert.equal(JSON.stringify((await pool.query('SELECT * FROM schedules WHERE route_id=$1 ORDER BY id', [routeIds[1]])).rows), reverseBefore);
|
|
|
|
// A future import closes old versions on the preceding day, keeping today's data intact.
|
|
sourceHtml = sourceHtml.replace('05:22', '05:23');
|
|
const inspected = await service.inspect({ routeIds, sourceUrl, userId: 1 });
|
|
const tomorrow = new Date(`${validFrom}T12:00:00Z`); tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
|
|
const future = await service.preview({ inspectionId: inspected.id, mappings: inspected.mappings, validFrom: tomorrow.toISOString().slice(0, 10), userId: 1 });
|
|
await service.apply(future.id);
|
|
const versions = (await pool.query("SELECT to_char(valid_until,'YYYY-MM-DD') AS until FROM schedules WHERE day_type='weekday' AND route_id = $1 ORDER BY id", [routeId])).rows;
|
|
assert.equal(versions.length, 2);
|
|
assert.equal(versions[0].until, validFrom);
|
|
assert.equal(versions[1].until, null);
|
|
|
|
// Copying the selected version must not choose a newer future version or overwrite history.
|
|
await pool.query(`INSERT INTO schedules (route_id,day_type,departure_times,trips,valid_from,valid_until)
|
|
VALUES ($1,'holiday',ARRAY['04:00'::time],'[]','2020-01-01','2020-01-02')`, [routeId]);
|
|
const vm = require('node:vm');
|
|
const moduleStub = { exports: {} };
|
|
vm.runInNewContext(await fs.readFile(path.join(__dirname, '../src/controllers/schedulesController.js'), 'utf8'), {
|
|
module: moduleStub, require: name => {
|
|
if (name.endsWith('/database')) return pool;
|
|
if (name.endsWith('/redis')) return { del: async () => {} };
|
|
if (name.endsWith('/logger')) return { info() {}, error() {} };
|
|
throw new Error(`Unexpected dependency: ${name}`);
|
|
}
|
|
});
|
|
const selectedId = (await pool.query("SELECT id FROM schedules WHERE day_type='weekday' AND route_id = $1 ORDER BY id LIMIT 1", [routeId])).rows[0].id;
|
|
let responseBody;
|
|
const response = { status(code) { assert.ok(code < 400); return this; }, json(body) { responseBody = body; } };
|
|
await moduleStub.exports.copySchedule({ params: { routeId }, body: { fromDayType: 'weekday', toDayType: 'holiday', sourceScheduleId: selectedId } }, response);
|
|
assert.equal(responseBody.trips.some(t => t.departureTime === '05:22:00'), true);
|
|
assert.equal(responseBody.trips.some(t => t.departureTime === '05:23:00'), false);
|
|
assert.equal(Number((await pool.query("SELECT count(*) FROM schedules WHERE day_type='holiday'")).rows[0].count), 2);
|
|
|
|
let news = await service.news(true);
|
|
assert.equal(news.length, 3);
|
|
await service.reviewNews({ url: news[0].url, contentHash: news[0].content_hash });
|
|
assert.equal((await service.news(true))[0].reviewed, true);
|
|
newsHtml = newsHtml.replace('08:50', '08:51');
|
|
news = await service.news(true);
|
|
assert.equal(news[0].reviewed, false);
|
|
assert.equal(news.length, 3);
|
|
} finally {
|
|
if (pool) await pool.end();
|
|
await admin.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);
|
|
await admin.end();
|
|
}
|
|
});
|