recordingTaskService.test.js 13.0 KB
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
    RecordingTaskService,
    buildManifestKey,
    normalizeOnlyHighlight,
    normalizeRecordingTask,
    parseTaskTime
} = require('../services/recordingTaskService');

async function waitForBackground(service) {
    for (let attempt = 0; attempt < 100; attempt += 1) {
        if (service.inFlight.size === 0) return;
        await new Promise(resolve => setTimeout(resolve, 10));
    }
    throw new Error('后台高光任务未在测试时间内结束');
}

async function run() {
    assert.strictEqual(normalizeOnlyHighlight(undefined), 0);
    assert.strictEqual(normalizeOnlyHighlight(0), 0);
    assert.strictEqual(normalizeOnlyHighlight('1'), 1);
    assert.strictEqual(normalizeOnlyHighlight(2), 0);

    assert.deepStrictEqual(normalizeRecordingTask({
        siteId: 'doctest',
        classId: '1001',
        yymmdd: '20260805'
    }), {
        taskId: '',
        siteId: 'doctest',
        classId: '1001',
        onlyHighlight: 0,
        beginTime: null,
        endTime: null,
        classDate: '20260805',
        classStartTime: '20260805'
    });

    const saasTask = normalizeRecordingTask({
        id: 'task-high-1',
        siteId: 'doctest',
        meetingNumber: '1002',
        beginTime: '2026-08-05 10:00:00',
        endTime: '2026-08-05 11:00:00',
        onlyHighlight: 1
    });
    assert.strictEqual(saasTask.classId, '1002');
    assert.strictEqual(saasTask.onlyHighlight, 1);
    assert.strictEqual(saasTask.classDate, '20260805');
    assert.strictEqual(saasTask.beginTime, 1785895200000);
    assert.strictEqual(parseTaskTime(1785895200000, 'beginTime'), 1785895200000);
    assert.throws(() => normalizeRecordingTask({
        siteId: 'doctest', classId: '1002', beginTime: 1785895200000
    }), /必须同时传入/);
    assert.throws(() => normalizeRecordingTask({
        siteId: 'doctest', classId: '1002', yymmdd: 1785895200000
    }), /yyyyMMdd/);

    const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'webscreen-v2-task-'));
    const configPath = path.join(tempRoot, 'config.json');
    fs.writeFileSync(configPath, JSON.stringify({
        PROJECTCATALOG: tempRoot,
        RECORDINGV2CONFIG: {
            taskListUrl: 'https://saas.xuedianyun.com/3m/api/recording/getRecordingTasksPrivate.do',
            pageSize: 2,
            maxPages: 10,
            maxTasksPerRun: 2,
            apiTimeoutMs: 10000,
            apiRetryCount: 0,
            apiRetryBaseDelayMs: 0
        },
        HIGHLIGHTCONFIG: {
            enabled: true,
            siteIds: ['doctest'],
            maxDurationMs: 3600000,
            outputBaseUrl: 'https://xdymp4.xuedianyun.com',
            outputNamespace: ''
        }
    }));

    const records = [{
        id: 5,
        meetingNumber: '1002',
        siteId: 'doctest',
        beginTime: 1785895298000,
        endTime: 1785895343000
    }, {
        id: 6,
        meetingNumber: '1002',
        siteId: 'doctest',
        beginTime: 1785895498000,
        endTime: 1785895543000
    }, {
        id: 7,
        meetingNumber: '1002',
        siteId: 'doctest',
        beginTime: 1785899498000,
        endTime: 1785899543000
    }];
    const calls = { fetch: 0, enqueue: 0, wait: 0 };
    const highlightService = {
        fetchByClass: async classId => {
            calls.fetch += 1;
            return classId === '1002' ? records : [];
        },
        enqueue: async items => {
            calls.enqueue += 1;
            return items.map(item => ({ status: 'queued', highlightId: item.highlightId }));
        },
        waitForTaskKeys: async keys => {
            calls.wait += 1;
            return keys.map(key => ({ key, status: 'uploading' }));
        }
    };
    const objects = new Set();
    const statusUpdates = [];
    const service = new RecordingTaskService({
        configPath,
        highlightService,
        inspectObject: async key => objects.has(key),
        updateTaskStatus: async (task, status) => { statusUpdates.push(`${task.classId}:${status}`); }
    });

    const fullCalls = [];
    const fullResult = await service.acceptTasks({
        list: [{
            id: 'task-full-1',
            siteId: 'doctest',
            classId: '1001',
            yymmdd: '20260805'
        }]
    }, {
        recordFullClass: task => { fullCalls.push(task); }
    });
    assert.deepStrictEqual(fullResult, { accepted: 1, duplicates: 0, noMedia: 0 });
    assert.strictEqual(fullCalls.length, 1);
    assert.strictEqual(fullCalls[0].onlyHighlight, 0);
    assert.strictEqual(calls.fetch, 0, '整课任务不得查询高光接口');

    let fullFile = await service.getFileResult({
        siteId: 'doctest', classId: '1001', classStartTime: '20260805'
    });
    assert.strictEqual(fullFile.fileExists, false);
    objects.add('oss/doctest/20260805/1001.mp4');
    fullFile = await service.getFileResult({ siteId: 'doctest', classId: '1001' });
    assert.strictEqual(fullFile.fileExists, true);
    assert.strictEqual(fullFile.onlyHighlight, 0);
    assert.strictEqual(fullFile.files.length, 1);
    assert.strictEqual(fullFile.classUrl, fullFile.files[0].url);

    const highResult = await service.acceptTasks({ list: [{
        id: 'task-high-1',
        siteId: 'doctest',
        meetingNumber: '1002',
        beginTime: '2026-08-05 10:00:00',
        endTime: '2026-08-05 11:00:00',
        onlyHighlight: 1
    }] });
    assert.deepStrictEqual(highResult, { accepted: 1, duplicates: 0, noMedia: 0 });
    await waitForBackground(service);
    assert.strictEqual(calls.fetch, 1);
    assert.strictEqual(calls.enqueue, 1);
    assert.strictEqual(calls.wait, 1);
    assert.deepStrictEqual(statusUpdates, ['1001:2', '1002:2']);

    const highManifest = service.loadManifest(buildManifestKey({ siteId: 'doctest', classId: '1002' }));
    assert.strictEqual(highManifest.onlyHighlight, 1);
    assert.strictEqual(highManifest.status, 'completed');
    assert.deepStrictEqual(highManifest.highlights.map(item => item.highlightId), [5, 6]);

    const duplicate = await service.acceptTasks({ list: [{
        id: 'task-high-1',
        siteId: 'doctest',
        meetingNumber: '1002',
        beginTime: '2026-08-05 10:00:00',
        endTime: '2026-08-05 11:00:00',
        onlyHighlight: 1
    }] });
    assert.deepStrictEqual(duplicate, { accepted: 0, duplicates: 1, noMedia: 0 });
    assert.strictEqual(calls.fetch, 1);

    let highFiles = await service.getFileResult({ siteId: 'doctest', classId: '1002' });
    assert.strictEqual(highFiles.fileExists, false);
    objects.add('oss/doctest/20260805/1002_highlight_5.mp4');
    objects.add('oss/doctest/20260805/1002_highlight_6.mp4');
    highFiles = await service.getFileResult({ siteId: 'doctest', classId: '1002' });
    assert.strictEqual(highFiles.fileExists, true);
    assert.strictEqual(highFiles.onlyHighlight, 1);
    assert.deepStrictEqual(highFiles.files.map(item => item.highlightId), [5, 6]);

    const noMedia = await service.acceptTasks({ list: [{
        id: 'task-high-empty',
        siteId: 'doctest',
        classId: '1003',
        yymmdd: '20260805',
        onlyHighlight: 1
    }] });
    assert.deepStrictEqual(noMedia, { accepted: 1, duplicates: 0, noMedia: 1 });
    assert.deepStrictEqual(statusUpdates, ['1001:2', '1002:2', '1003:3']);
    const emptyResult = await service.getFileResult({ siteId: 'doctest', classId: '1003' });
    assert.strictEqual(emptyResult.fileExists, false);

    const mappedNoMedia = await service.acceptTasks({ list: [{
        siteId: 'doctest',
        classId: '1006',
        yymmdd: '20260805',
        onlyHighlight: 1
    }] });
    assert.deepStrictEqual(mappedNoMedia, { accepted: 1, duplicates: 0, noMedia: 1 });
    assert.strictEqual(statusUpdates[statusUpdates.length - 1], '1006:3',
        '没有 SaaS 任务 id 也必须回写状态');

    const retryTask = normalizeRecordingTask({
        id: 'task-high-retry',
        siteId: 'doctest',
        classId: '1004',
        yymmdd: '20260805',
        onlyHighlight: 1
    });
    service.saveManifest(service.buildManifest(retryTask, [], 'failed'));
    const retryResult = await service.acceptTasks({ list: [{
        id: 'task-high-retry',
        siteId: 'doctest',
        classId: '1004',
        yymmdd: '20260805',
        onlyHighlight: 1
    }] });
    assert.deepStrictEqual(retryResult, { accepted: 1, duplicates: 0, noMedia: 1 },
        '失败的高光任务必须允许使用相同任务 ID 重试');

    const fallback = await service.getFileResult({
        siteId: 'doctest', classId: 'legacy', classStartTime: '20260805'
    });
    assert.strictEqual(fallback.fileExists, false);
    objects.add('oss/doctest/20260805/legacy.mp4');
    const generatedFallback = await service.getFileResult({
        siteId: 'doctest', classId: 'legacy', classStartTime: '20260805'
    });
    assert.strictEqual(generatedFallback.fileExists, true);

    let releaseConcurrentFetch;
    let concurrentFetches = 0;
    const concurrentService = new RecordingTaskService({
        configPath,
        highlightService: {
            fetchByClass: async () => {
                concurrentFetches += 1;
                return new Promise(resolve => { releaseConcurrentFetch = resolve; });
            }
        },
        updateTaskStatus: async () => {}
    });
    const concurrentTask = {
        id: 'task-high-concurrent',
        siteId: 'doctest',
        classId: '1005',
        yymmdd: '20260805',
        onlyHighlight: 1
    };
    const firstConcurrent = concurrentService.acceptTasks({ list: [concurrentTask] });
    await new Promise(resolve => setImmediate(resolve));
    const duplicateConcurrent = await concurrentService.acceptTasks({ list: [concurrentTask] });
    assert.deepStrictEqual(duplicateConcurrent, { accepted: 0, duplicates: 1, noMedia: 0 });
    assert.strictEqual(concurrentFetches, 1, '并发重投不得重复查询 SaaS 高光接口');
    releaseConcurrentFetch([]);
    assert.deepStrictEqual(await firstConcurrent, { accepted: 1, duplicates: 0, noMedia: 1 });

    const scheduledFullCalls = [];
    const scheduledService = new RecordingTaskService({
        configPath,
        highlightService,
        updateTaskStatus: async () => {},
        fetchTaskPage: async pageNo => ({
            code: 0,
            taskCount: 3,
            pageNo,
            pageSize: 2,
            taskList: pageNo === 1 ? [{
                id: 'scheduled-1', siteId: 'doctest', meetingNumber: '1010',
                beginTime: '2026-08-05 10:00:00', endTime: '2026-08-05 11:00:00', onlyHighlight: 0
            }, {
                id: 'scheduled-2', siteId: 'doctest', meetingNumber: '1011',
                beginTime: '2026-08-05 10:00:00', endTime: '2026-08-05 11:00:00', onlyHighlight: 0
            }] : [{
                id: 'scheduled-3', siteId: 'doctest', meetingNumber: '1012',
                beginTime: '2026-08-05 10:00:00', endTime: '2026-08-05 11:00:00', onlyHighlight: 0
            }]
        })
    });
    const scheduledHandlers = {
        recordFullClass: task => { scheduledFullCalls.push(task.classId); }
    };
    const firstScheduled = await scheduledService.runScheduledTaskPull(scheduledHandlers);
    assert.deepStrictEqual(firstScheduled, {
        received: 3,
        taskCount: 3,
        pages: 2,
        inspected: 2,
        accepted: 2,
        duplicates: 0,
        noMedia: 0,
        invalid: 0,
        limited: true
    });
    const secondScheduled = await scheduledService.runScheduledTaskPull(scheduledHandlers);
    assert.strictEqual(secondScheduled.inspected, 3);
    assert.strictEqual(secondScheduled.accepted, 1);
    assert.strictEqual(secondScheduled.duplicates, 2);
    assert.strictEqual(secondScheduled.limited, false);
    assert.deepStrictEqual(scheduledFullCalls, ['1010', '1011', '1012']);
    await waitForBackground(scheduledService);

    const fullQueueStarts = [];
    const fullQueueReleases = [];
    const fullQueueService = new RecordingTaskService({
        configPath,
        highlightService,
        updateTaskStatus: async () => {}
    });
    const fullQueueResult = await fullQueueService.acceptTasks({
        list: ['1020', '1021', '1022'].map(classId => ({
            id: `queue-${classId}`,
            siteId: 'doctest',
            classId,
            yymmdd: '20260805'
        }))
    }, {
        recordFullClass: task => new Promise(resolve => {
            fullQueueStarts.push(task.classId);
            fullQueueReleases.push(resolve);
        })
    });
    assert.strictEqual(fullQueueResult.accepted, 3);
    assert.deepStrictEqual(fullQueueStarts, ['1020', '1021']);
    assert.strictEqual(fullQueueService.fullClassActiveCount, 2);
    assert.strictEqual(fullQueueService.fullClassQueue.length, 1);
    fullQueueReleases[0]();
    await new Promise(resolve => setImmediate(resolve));
    assert.deepStrictEqual(fullQueueStarts, ['1020', '1021', '1022']);
    fullQueueReleases[1]();
    fullQueueReleases[2]();
    await waitForBackground(fullQueueService);

    fs.rmSync(tempRoot, { recursive: true, force: true });
    console.log('recording task V2 service tests passed');
}

run().catch(error => {
    console.error(error);
    process.exitCode = 1;
});