187 lines
9.1 KiB
JavaScript
187 lines
9.1 KiB
JavaScript
const assert = require("assert");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const Database = require("better-sqlite3");
|
|
const isolatedDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-user-age-"));
|
|
process.env.LUMI_DATA_DIR = isolatedDataDir;
|
|
const { formatDuration, parseDurationFormat } = require("../src/services/duration-format");
|
|
const { UserAgeStatistics } = require("../src/services/user-age-statistics");
|
|
const placeholders = require("../src/services/placeholders");
|
|
const coreDatabase = require("../src/services/db");
|
|
coreDatabase.migrate();
|
|
|
|
const hour = 60 * 60 * 1000;
|
|
const day = 24 * hour;
|
|
let now = 100 * day;
|
|
const database = new Database(":memory:");
|
|
database.exec(`
|
|
CREATE TABLE user_profiles (
|
|
id TEXT PRIMARY KEY,
|
|
internal_username TEXT NOT NULL
|
|
);
|
|
CREATE TABLE user_identities (
|
|
user_id TEXT NOT NULL,
|
|
provider TEXT NOT NULL,
|
|
provider_user_id TEXT NOT NULL
|
|
);
|
|
`);
|
|
const service = new UserAgeStatistics(database, { now: () => now });
|
|
database.prepare("INSERT INTO user_profiles (id, internal_username) VALUES (?, ?)").run("profile-1", "ViewerOne");
|
|
database.prepare("INSERT INTO user_identities (user_id, provider, provider_user_id) VALUES (?, ?, ?)").run("profile-1", "twitch", "viewer-1");
|
|
|
|
const base = {
|
|
platform: "twitch",
|
|
userId: "viewer-1",
|
|
scopeId: "channel-1",
|
|
type: "follow",
|
|
source: "test-event",
|
|
authoritative: true
|
|
};
|
|
|
|
service.setState({ ...base, active: true, startAt: now - 10 * day, occurredAt: now, eventId: "follow-1" });
|
|
let rows = service.listDiagnostics({ platform: "twitch", userId: "viewer-1", type: "follow" });
|
|
assert.strictEqual(rows.length, 1, "new follow should create one interval");
|
|
assert.strictEqual(rows[0].end_at, null, "new follow interval should remain open");
|
|
|
|
now += 2 * day;
|
|
service.setState({ ...base, active: false, occurredAt: now, eventId: "unfollow-1" });
|
|
rows = service.listDiagnostics({ platform: "twitch", userId: "viewer-1", type: "follow" });
|
|
assert.strictEqual(rows[0].end_at, now, "unfollow should close the open interval");
|
|
const firstCompleted = { ...rows[0] };
|
|
|
|
now += day;
|
|
service.setState({ ...base, active: true, startAt: now, occurredAt: now, eventId: "follow-2" });
|
|
service.setState({ ...base, active: true, startAt: now, occurredAt: now, eventId: "follow-2" });
|
|
rows = service.listDiagnostics({ platform: "twitch", userId: "viewer-1", type: "follow" });
|
|
assert.strictEqual(rows.length, 2, "refollow should create exactly one second interval");
|
|
assert.deepStrictEqual(
|
|
Object.fromEntries(Object.entries(rows.find((row) => row.id === firstCompleted.id)).filter(([key]) => !["updated_at"].includes(key))),
|
|
Object.fromEntries(Object.entries(firstCompleted).filter(([key]) => !["updated_at"].includes(key))),
|
|
"completed intervals must not be modified"
|
|
);
|
|
|
|
now += 3 * day;
|
|
let durations = service.getDurations(base);
|
|
assert.strictEqual(durations.current, 3 * day, "current should include only the open interval");
|
|
assert.strictEqual(durations.total, 15 * day, "total should combine completed and active intervals");
|
|
const leaders = service.getLeaderboard("follow", { platform: "twitch", limit: 10 });
|
|
assert.strictEqual(leaders[0].username, "ViewerOne", "leaderboards should resolve linked Lumi profiles");
|
|
assert.ok(leaders.every((entry) => !entry.label.includes("channel-1")), "leaderboards must not expose channel identifiers beside usernames");
|
|
|
|
service.setState({ ...base, active: false, occurredAt: now, eventId: "unfollow-2" });
|
|
durations = service.getDurations(base);
|
|
assert.strictEqual(durations.current, null, "inactive state should have no current duration");
|
|
|
|
service.setState({ ...base, scopeId: "channel-2", active: true, occurredAt: now, eventId: "channel-2-follow" });
|
|
assert.strictEqual(service.getDurations({ ...base, scopeId: "channel-1" }).current, null, "channels must remain isolated");
|
|
assert.strictEqual(service.getDurations({ ...base, scopeId: "channel-2" }).current, 0, "second channel should have its own interval");
|
|
const profileStatistics = service.getProfileStatistics("profile-1");
|
|
assert.ok(profileStatistics.every((entry) => !entry.label.includes("channel-1") && !entry.label.includes("channel-2")),
|
|
"profile statistics must use friendly channel labels without exposing IDs");
|
|
assert.ok(profileStatistics.some((entry) => entry.label.includes("Channel 1")) &&
|
|
profileStatistics.some((entry) => entry.label.includes("Channel 2")),
|
|
"multiple isolated scopes must remain distinguishable without raw IDs");
|
|
const multiScopeLeaders = service.getLeaderboard("follow", { platform: "twitch", limit: 10 });
|
|
assert.strictEqual(multiScopeLeaders.filter((entry) => entry.username === "ViewerOne").length, 1,
|
|
"leaderboards must collapse hidden channel scopes into one user row");
|
|
assert.strictEqual(multiScopeLeaders[0].numericValue, 15 * day,
|
|
"leaderboards must rank a user's longest scoped tenure without double-counting concurrent channels");
|
|
|
|
service.setState({
|
|
platform: "discord",
|
|
userId: "viewer-1",
|
|
scopeId: "guild-1",
|
|
type: "member",
|
|
active: true,
|
|
occurredAt: now,
|
|
eventId: "guild-1-member",
|
|
source: "test"
|
|
});
|
|
assert.strictEqual(service.getDurations({
|
|
platform: "discord",
|
|
userId: "viewer-1",
|
|
scopeId: "guild-2",
|
|
type: "member"
|
|
}).total, null, "Discord guilds must remain isolated");
|
|
|
|
const combined = 2 * 30 * day + 3 * day + 4 * hour + 47 * 60 * 1000;
|
|
assert.strictEqual(formatDuration(combined, "MM-DD-mm"), "2 months, 3 days, 287 minutes", "omitted hours should flow into minutes");
|
|
assert.strictEqual(formatDuration(3 * day + 2 * hour, "HH-mm"), "74 hours", "larger omitted units should flow into the largest selected unit");
|
|
assert.strictEqual(formatDuration(287 * 60 * 1000, "mm"), "287 minutes", "minutes-only format should contain total minutes");
|
|
assert.strictEqual(formatDuration(day + hour + 60 * 1000, "DD-HH-mm"), "1 day, 1 hour, 1 minute", "singular labels should be correct");
|
|
assert.strictEqual(formatDuration(2 * day + 2 * hour + 2 * 60 * 1000, "D-H-m"), "2 days, 2 hours, 2 minutes", "plural labels should be correct");
|
|
assert.strictEqual(formatDuration(500, "s"), "", "all-zero selected units should return an empty string");
|
|
assert.throws(() => parseDurationFormat("DD-YY"), /largest to smallest/, "out-of-order units should fail deterministically");
|
|
|
|
const restarted = new UserAgeStatistics(database, { now: () => now });
|
|
const beforeRestart = restarted.listDiagnostics({ platform: "twitch", scopeId: "channel-2", type: "follow" });
|
|
restarted.reconcileSnapshot({
|
|
platform: "twitch",
|
|
scopeId: "channel-2",
|
|
type: "follow",
|
|
activeRecords: [{ userId: "viewer-1" }],
|
|
observedAt: now,
|
|
source: "restart-test",
|
|
complete: true
|
|
});
|
|
const afterRestart = restarted.listDiagnostics({ platform: "twitch", scopeId: "channel-2", type: "follow" });
|
|
assert.strictEqual(afterRestart.length, beforeRestart.length, "restart reconciliation must not duplicate valid intervals");
|
|
assert.strictEqual(afterRestart[0].end_at, null, "restart reconciliation must not close valid intervals");
|
|
|
|
restarted.setState({
|
|
platform: "youtube",
|
|
userId: "viewer-2",
|
|
scopeId: "youtube-channel",
|
|
type: "subscriber",
|
|
active: true,
|
|
occurredAt: now,
|
|
eventId: "youtube-observed",
|
|
source: "youtube-first-observed",
|
|
authoritative: false
|
|
});
|
|
const observed = restarted.listDiagnostics({ platform: "youtube", userId: "viewer-2" })[0];
|
|
assert.strictEqual(observed.start_at, now, "unavailable historical starts must begin at first observation");
|
|
assert.strictEqual(observed.start_authoritative, 0, "first-observed timestamps must not be marked authoritative");
|
|
|
|
placeholders.registerCorePlaceholders({ userAgeStatistics: restarted });
|
|
const invalidFormat = placeholders.validateTemplate({
|
|
fieldId: "core.custom_commands.static_response",
|
|
template: "{{twitch.user.follow_age.current}.{format.YYY-DD}}",
|
|
outputAudience: "user",
|
|
runtimeContext: { runtime: true }
|
|
});
|
|
assert.strictEqual(invalidFormat.ok, false, "unsupported duration tokens should fail shared placeholder validation");
|
|
const existing = placeholders.renderTemplate({
|
|
fieldId: "core.custom_commands.static_response",
|
|
template: "Hello {{user.public.display_name}}",
|
|
outputAudience: "user",
|
|
runtimeContext: { runtime: true, user: { displayName: "Lumi Friend" } }
|
|
});
|
|
const formatted = placeholders.renderTemplate({
|
|
fieldId: "core.custom_commands.static_response",
|
|
template: "{{twitch.user.follow_age.current}.{format.DD-HH-mm}}",
|
|
outputAudience: "user",
|
|
runtimeContext: {
|
|
runtime: true,
|
|
platform: "twitch",
|
|
user: { platformId: "viewer-1" },
|
|
ctx: { meta: { tags: { "room-id": "channel-2" } } }
|
|
}
|
|
});
|
|
|
|
Promise.all([existing, formatted]).then(([existingResult, formattedResult]) => {
|
|
assert.strictEqual(existingResult.rendered, "Hello Lumi Friend", "existing placeholders must remain unchanged");
|
|
assert.strictEqual(formattedResult.rendered, "", "a zero-duration current interval should render empty");
|
|
database.close();
|
|
coreDatabase.db.close();
|
|
fs.rmSync(isolatedDataDir, { recursive: true, force: true });
|
|
console.log("User-age statistics verification passed.");
|
|
}).catch((error) => {
|
|
database.close();
|
|
coreDatabase.db.close();
|
|
fs.rmSync(isolatedDataDir, { recursive: true, force: true });
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|