From 1f49c5554f8f54056bca9bac900c48a23d2f67e6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:54:02 +0000 Subject: [PATCH] test(ai): add unit tests for nip46Queue and moderationActionController (agent) Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- tests/nostr-nip46-queue.test.mjs | 136 ++++++++++++ .../moderation-action-controller.test.mjs | 203 ++++++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 tests/nostr-nip46-queue.test.mjs create mode 100644 tests/services/moderation-action-controller.test.mjs diff --git a/tests/nostr-nip46-queue.test.mjs b/tests/nostr-nip46-queue.test.mjs new file mode 100644 index 00000000..6c552640 --- /dev/null +++ b/tests/nostr-nip46-queue.test.mjs @@ -0,0 +1,136 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Nip46RequestQueue, NIP46_PRIORITY } from "../js/nostr/nip46Queue.js"; + +test("Nip46RequestQueue: processes tasks in FIFO order for same priority", async () => { + const queue = new Nip46RequestQueue({ minDelayMs: 0 }); + const results = []; + + const task1 = () => { + results.push(1); + return Promise.resolve(1); + }; + const task2 = () => { + results.push(2); + return Promise.resolve(2); + }; + const task3 = () => { + results.push(3); + return Promise.resolve(3); + }; + + await Promise.all([ + queue.enqueue(task1), + queue.enqueue(task2), + queue.enqueue(task3), + ]); + + assert.deepEqual(results, [1, 2, 3]); +}); + +test("Nip46RequestQueue: respects priority levels", async () => { + const queue = new Nip46RequestQueue({ minDelayMs: 0 }); + const results = []; + + // Enqueue tasks. The queue starts processing immediately, so we need to + // delay the start slightly or ensure they are added before the first one finishes? + // Actually, since tasks are async, the first one starts running. + // But subsequent ones will be queued. + + // To ensure they are all in the queue before processing picks the next one, + // we can make the first task slow. + + let releaseFirst; + const firstTaskPromise = new Promise(resolve => releaseFirst = resolve); + + const task1 = async () => { + await firstTaskPromise; + results.push("low"); + return "low"; + }; + + const task2 = async () => { + results.push("normal"); + return "normal"; + }; + + const task3 = async () => { + results.push("high"); + return "high"; + }; + + const p1 = queue.enqueue(task1, NIP46_PRIORITY.LOW); + // Give a tiny moment for p1 to start running (and block on firstTaskPromise) + await new Promise(r => setTimeout(r, 10)); + + const p2 = queue.enqueue(task2, NIP46_PRIORITY.NORMAL); + const p3 = queue.enqueue(task3, NIP46_PRIORITY.HIGH); + + releaseFirst(); + + await Promise.all([p1, p2, p3]); + + // task1 started first. While it was running, task2 and task3 were added. + // task3 has higher priority than task2, so it should run after task1 finishes. + assert.deepEqual(results, ["low", "high", "normal"]); +}); + +test("Nip46RequestQueue: respects minDelayMs", async () => { + const minDelayMs = 50; + const queue = new Nip46RequestQueue({ minDelayMs }); + + const start = Date.now(); + const times = []; + + const task = async () => { + times.push(Date.now()); + }; + + await Promise.all([ + queue.enqueue(task), + queue.enqueue(task), + queue.enqueue(task), + ]); + + assert.equal(times.length, 3); + const diff1 = times[1] - times[0]; + const diff2 = times[2] - times[1]; + + // Allow some margin for execution time + assert.ok(diff1 >= minDelayMs - 10, `Delay ${diff1}ms should be >= ${minDelayMs}ms`); + assert.ok(diff2 >= minDelayMs - 10, `Delay ${diff2}ms should be >= ${minDelayMs}ms`); +}); + +test("Nip46RequestQueue: clear() rejects pending tasks", async () => { + const queue = new Nip46RequestQueue({ minDelayMs: 100 }); + const results = []; + const errors = []; + + // Start a task that blocks the queue for a bit + queue.enqueue(async () => { + await new Promise(r => setTimeout(r, 50)); + results.push("finished"); + }); + + // Add tasks that will be pending + const p2 = queue.enqueue(async () => { + results.push("should not run"); + }).catch(e => errors.push(e.message)); + + const p3 = queue.enqueue(async () => { + results.push("should not run either"); + }).catch(e => errors.push(e.message)); + + // Clear the queue immediately + queue.clear(new Error("Queue cleared manually")); + + await Promise.allSettled([p2, p3]); + + // wait for the first task to finish naturally (it was already running) + await new Promise(r => setTimeout(r, 100)); + + assert.ok(results.includes("finished")); + assert.ok(!results.includes("should not run")); + assert.equal(errors.length, 2); + assert.equal(errors[0], "Queue cleared manually"); +}); diff --git a/tests/services/moderation-action-controller.test.mjs b/tests/services/moderation-action-controller.test.mjs new file mode 100644 index 00000000..27c48b25 --- /dev/null +++ b/tests/services/moderation-action-controller.test.mjs @@ -0,0 +1,203 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import ModerationActionController from "../../js/services/moderationActionController.js"; + +test("ModerationActionController: handles moderation override", async () => { + const overrides = []; + const events = []; + + const mockServices = { + setModerationOverride: (descriptor, opts) => overrides.push({ descriptor, opts }), + clearModerationOverride: () => {}, + userBlocks: {}, + }; + + const mockSelectors = { + getVideoById: (id) => ({ id, moderation: { hidden: true } }), + getCurrentVideo: () => ({ id: "video1", moderation: { hidden: true } }), + }; + + const mockUi = { + refreshCardModerationUi: () => {}, + dispatchModerationEvent: (name, detail) => events.push({ name, detail }), + }; + + const mockActions = { + decorateVideoModeration: (video) => { video.decorated = true; }, + resumePlayback: () => {}, + }; + + const mockAuth = { + normalizePubkey: (k) => k, + }; + + const controller = new ModerationActionController({ + services: mockServices, + selectors: mockSelectors, + ui: mockUi, + actions: mockActions, + auth: mockAuth, + }); + + const video = { id: "video1", pubkey: "author1" }; + const result = controller.handleOverride({ video }); + + assert.equal(result, true); + assert.equal(overrides.length, 1); + assert.equal(overrides[0].descriptor.eventId, "video1"); + assert.equal(overrides[0].descriptor.authorPubkey, "author1"); + + assert.equal(events.length, 1); + assert.equal(events[0].name, "video:moderation-override"); + assert.equal(events[0].detail.video.id, "video1"); +}); + +test("ModerationActionController: blocks user and updates state", async () => { + const blocks = []; + const events = []; + const errors = []; + + const mockServices = { + userBlocks: { + ensureLoaded: async () => {}, + isBlocked: () => false, + addBlock: async (target, viewer) => { + blocks.push({ target, viewer }); + return { already: false }; + }, + }, + clearModerationOverride: () => {}, + }; + + const mockAuth = { + isLoggedIn: () => true, + getViewerPubkey: () => "viewer1", + normalizePubkey: (k) => k, + }; + + const mockUi = { + refreshCardModerationUi: () => {}, + dispatchModerationEvent: (name, detail) => events.push({ name, detail }), + }; + + const mockActions = { + showStatus: () => {}, + showError: (msg) => errors.push(msg), + refreshVideos: async () => {}, + decorateVideoModeration: () => {}, + }; + + const controller = new ModerationActionController({ + services: mockServices, + auth: mockAuth, + ui: mockUi, + actions: mockActions, + }); + + const video = { id: "video1", pubkey: "spammer" }; + const result = await controller.handleBlock({ video }); + + assert.equal(result, true); + assert.equal(blocks.length, 1); + assert.equal(blocks[0].target, "spammer"); + assert.equal(blocks[0].viewer, "viewer1"); + + // Should trigger block and hide events + assert.ok(events.find(e => e.name === "video:moderation-block")); + assert.ok(events.find(e => e.name === "video:moderation-hide")); + assert.equal(errors.length, 0); +}); + +test("ModerationActionController: prevents blocking self", async () => { + const errors = []; + + const mockAuth = { + isLoggedIn: () => true, + getViewerPubkey: () => "me", + normalizePubkey: (k) => k, + }; + + const mockServices = { + userBlocks: { ensureLoaded: async () => {} }, + }; + + const mockActions = { + showError: (msg) => errors.push(msg), + }; + + const controller = new ModerationActionController({ + services: mockServices, + auth: mockAuth, + actions: mockActions, + }); + + const video = { id: "video1", pubkey: "me" }; + const result = await controller.handleBlock({ video }); + + assert.equal(result, false); + assert.ok(errors[0].includes("cannot block yourself")); +}); + +test("ModerationActionController: requires login to block", async () => { + const statuses = []; + + const mockAuth = { + isLoggedIn: () => false, + }; + + const mockServices = { + userBlocks: {}, + }; + + const mockActions = { + showStatus: (msg) => statuses.push(msg), + }; + + const controller = new ModerationActionController({ + services: mockServices, + auth: mockAuth, + actions: mockActions, + }); + + const video = { id: "video1", pubkey: "someone" }; + const result = await controller.handleBlock({ video }); + + assert.equal(result, false); + assert.ok(statuses[0].includes("Log in")); +}); + +test("ModerationActionController: handles hide action", async () => { + const events = []; + + const mockServices = { + clearModerationOverride: () => {}, + }; + + const mockSelectors = { + getVideoById: (id) => ({ id }), + getCurrentVideo: () => null, + }; + + const mockUi = { + refreshCardModerationUi: () => {}, + dispatchModerationEvent: (name, detail) => events.push({ name, detail }), + }; + + const mockActions = { + decorateVideoModeration: () => {}, + }; + + const controller = new ModerationActionController({ + services: mockServices, + selectors: mockSelectors, + ui: mockUi, + actions: mockActions, + }); + + const video = { id: "video1" }; + const result = controller.handleHide({ video }); + + assert.equal(result, true); + assert.equal(events.length, 1); + assert.equal(events[0].name, "video:moderation-hide"); +});