Files
NodecastV2/test/db-concurrency.test.js
2026-10-13 20:29:08 -07:00

129 lines
7.6 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const vm = require('node:vm');
const { createRequire } = require('node:module');
async function fixture(t) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-db-test-'));
t.after(async () => {
assert.equal(path.dirname(path.resolve(dir)), path.resolve(os.tmpdir()));
await fs.rm(dir, { recursive: true, force: true });
});
const filename = path.join(__dirname, '..', 'server', 'db.js');
const module = { exports: {} };
vm.runInNewContext(await fs.readFile(filename, 'utf8'), {
module, require: createRequire(filename), __dirname: path.dirname(filename),
process: { env: { NODECAST_DATA_DIR: dir } }, console: { error() {}, log() {} }, setTimeout
}, { filename });
return { db: module.exports, dir };
}
test('concurrent source creation retains every record and unique ID', async t => {
const { db } = await fixture(t);
const created = await Promise.all(Array.from({ length: 12 }, (_, index) =>
db.sources.create({ name: `Source ${index}`, type: 'm3u', url: `https://example.invalid/${index}` })));
assert.equal((await db.sources.getAll()).length, 12);
assert.equal(new Set(created.map(source => source.id)).size, 12);
await Promise.all([db.sources.update(created[0].id, { name: 'Renamed' }),
db.sources.update(created[0].id, { url: 'https://example.invalid/new' })]);
const source = await db.sources.getById(created[0].id);
assert.equal(source.name, 'Renamed');
assert.equal(source.url, 'https://example.invalid/new');
});
test('mixed writes and duplicate checks are atomic across all JSON stores', async t => {
const { db } = await fixture(t);
await Promise.all([db.sources.create({ name: 'Source' }), db.users.create({ username: 'Viewer' }),
db.settings.update({ defaultVolume: 37 }), db.hiddenItems.hide(1, 'channel', '1'), db.favorites.add(1, '2')]);
const data = await db.loadDb();
assert.equal(data.sources.length, 1);
assert.equal(data.users.length, 1);
assert.equal(data.hiddenItems.length, 1);
assert.equal(data.favorites.length, 1);
assert.equal(data.settings.defaultVolume, 37);
const duplicates = await Promise.allSettled([db.users.create({ username: 'Same' }), db.users.create({ username: 'Same' })]);
assert.equal(duplicates.filter(result => result.status === 'fulfilled').length, 1);
await Promise.all([db.favorites.add(1, '2'), db.favorites.add(1, '2'), db.hiddenItems.hide(1, 'channel', '1')]);
assert.equal((await db.favorites.getAll()).length, 1);
assert.equal((await db.hiddenItems.getAll()).length, 1);
});
test('concurrent demotions and deletions cannot remove the last administrator', async t => {
const { db } = await fixture(t);
const first = await db.users.create({ username: 'First', role: 'admin' });
const second = await db.users.create({ username: 'Second', role: 'admin' });
const results = await Promise.allSettled([db.users.update(first.id, { role: 'viewer' }), db.users.delete(second.id)]);
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
assert.equal((await db.users.getAll()).filter(user => user.role === 'admin').length, 1);
});
test('write failures preserve data and do not poison the queue; corrupt data is never overwritten', async t => {
const { db, dir } = await fixture(t);
await db.sources.create({ name: 'Kept' });
await fs.mkdir(path.join(dir, 'db.json.tmp'));
await assert.rejects(db.sources.create({ name: 'Failed' }));
assert.equal((await db.sources.getAll()).length, 1);
await fs.rmdir(path.join(dir, 'db.json.tmp'));
await db.sources.create({ name: 'Recovered' });
assert.equal((await db.sources.getAll()).length, 2);
const corrupt = '{invalid-json';
await fs.writeFile(path.join(dir, 'db.json'), corrupt);
await assert.rejects(db.settings.update({ defaultVolume: 1 }));
assert.equal(await fs.readFile(path.join(dir, 'db.json'), 'utf8'), corrupt);
});
test('legacy user IDs and unknown metadata survive new writes', async t => {
const { db, dir } = await fixture(t);
await fs.writeFile(path.join(dir, 'db.json'), JSON.stringify({ users: [{ id: 30, username: 'Legacy' }], nextUserId: 31 }));
const created = await db.users.create({ username: 'New' });
assert.equal(created.id, 31);
assert.equal((await db.loadDb()).nextUserId, 31);
});
test('source route does not reapply fields from a stale read', async t => {
const { db } = await fixture(t);
const source = await db.sources.create({ name: 'Original', url: 'https://example.invalid/original' });
const stale = await db.sources.getById(source.id);
await db.sources.update(source.id, { url: 'https://example.invalid/current' });
const filename = path.join(__dirname, '..', 'server', 'routes', 'sources.js');
const realRequire = createRequire(filename), routes = [];
const router = { get() {}, post() {}, delete() {}, put(route, handler) { routes.push(handler); } };
const module = { exports: {} };
vm.runInNewContext(await fs.readFile(filename, 'utf8'), {
module, console, require(id) {
if (id === 'express') return { Router: () => router };
if (id === '../db') return { sources: { ...db.sources, getById: async () => stale } };
if (id === '../db/sqlite') return {};
if (id === '../services/syncService') return { syncSource: async () => {} };
return realRequire(id);
}
}, { filename });
const res = { status() { return this; }, json(value) { this.value = value; } };
await routes[0]({ params: { id: source.id }, body: { name: 'Renamed' } }, res);
assert.equal(res.value.url, 'https://example.invalid/current');
assert.equal(res.value.name, 'Renamed');
});
test('case-folded username creation and renames are atomic without merging legacy accounts', async t => {
const { db, dir } = await fixture(t);
const created = await Promise.allSettled(['Alice', 'ALICE', 'alice'].map(username => db.users.create({ username })));
assert.equal(created.filter(result => result.status === 'fulfilled').length, 1);
const alice = (await db.users.getAll())[0]; assert.equal((await db.users.getByUsername('aLiCe')).id, alice.id);
for (const username of [null, [], {}, '', ' ', 'a'.repeat(65), 'bad\nname']) await assert.rejects(db.users.create({ username }));
const bob = await db.users.create({ username: 'Bob' });
await assert.rejects(db.users.update(bob.id, { username: 'ALICE' }), /already exists/);
await db.users.update(alice.id, { username: 'ALICE' }); assert.equal((await db.users.getByUsername('alice')).id, alice.id);
const renames = await Promise.allSettled([db.users.update(alice.id, { username: 'Shared' }), db.users.update(bob.id, { username: 'SHARED' })]);
assert.equal(renames.filter(result => result.status === 'fulfilled').length, 1);
assert.equal((await db.users.getAll()).length, 2);
const legacy = { users: [{ id: 1, username: 'Old', role: 'admin' }, { id: 2, username: 'old', role: 'viewer' }], nextId: 3 };
await fs.writeFile(path.join(dir, 'db.json'), JSON.stringify(legacy));
assert.equal((await db.users.getByUsername('Old')).id, 1); assert.equal((await db.users.getByUsername('old')).id, 2);
assert.equal(await db.users.getByUsername('OLD'), undefined);
await db.users.update(2, { username: 'old', passwordHash: 'changed' });
const saved = await db.loadDb(); assert.equal(saved.users.length, 2); assert.equal(saved.users[1].authVersion, 1);
});