129 lines
7.6 KiB
JavaScript
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);
|
|
});
|