Files
bitvid/tests/search-filters.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 9afbc0cbbb [search] Decode npub authors to hex — the Author filter matched nothing (npubs)
The Author field (and the author: token) passed values through as-typed, but the
matcher compares against video.pubkey — HEX. So entering an npub (the only format
users actually have) silently returned zero results; only raw hex worked.

New normalizeAuthorValues (searchFilters.js, injectable decoder for tests):
npub1* decodes via safeDecodeNpub to hex, hex passes through lowercased, and
undecodable values are dropped — surfaced as parse errors on the author: token
path. Applied in BOTH the parser and the filter modal's applyFilters, so the
Author panel field and the token syntax now genuinely filter by creator.

Tests: +2 in tests/search-filters.test.mjs (decode/passthrough/drop with an
injected decoder; parser hex passthrough + garbage error). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 23:12:20 -04:00

161 lines
6.2 KiB
JavaScript

// Search pipeline coverage (was zero): the token parser, the video filter
// matcher, and the result sorter — the pure core of search.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-search-core
// given: "query strings / videos with varying metadata"
// when: "parseFilterQuery / buildVideoSearchFilterMatcher / sortSearchResults run"
// then: "tokens parse into filters; matcher enforces them; sort honors the selected mode"
// observable_outcomes:
// - "tag:/has:/duration:< tokens land in filters + free text preserved"
// - "matcher enforces tags, date range, has:magnet, duration bounds"
// - "sort: views ranks by injected view counts; longest by duration; recent by created_at"
// determinism_controls:
// - "pure functions; injected getViews + fixed now"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import "./test-helpers/setup-localstorage.mjs";
import test from "node:test";
import { strict as assert } from "node:assert";
import { parseFilterQuery } from "../js/search/searchFilters.js";
import {
buildVideoSearchFilterMatcher,
sortSearchResults,
} from "../js/search/searchFilterMatchers.js";
test("parser: tokens land in filters, free text preserved", () => {
const { text, filters, errors } = parseFilterQuery(
"cat videos tag:nostr has:magnet duration:<120",
);
assert.equal(errors.length, 0);
assert.equal(text, "cat videos");
assert.deepEqual(filters.tags, ["nostr"]);
assert.equal(filters.hasMagnet, true);
assert.equal(filters.duration.maxSeconds, 120);
});
test("matcher enforces tags, date range, has:magnet, duration", () => {
const video = {
pubkey: "a".repeat(64),
created_at: 1_700_000_000,
tags: [["t", "nostr"]],
magnet: "magnet:?xt=urn:btih:abc",
nip71: { duration: 90 }, // ingested NIP-71 shape (bitvid kind uses top-level)
};
const pass = buildVideoSearchFilterMatcher({
tags: ["nostr"],
hasMagnet: true,
duration: { minSeconds: 60, maxSeconds: 120 },
dateRange: { after: 1_699_999_999, before: 1_700_000_001 },
});
assert.equal(pass(video), true);
assert.equal(pass({ ...video, tags: [] }), false, "missing tag rejected");
assert.equal(pass({ ...video, magnet: "" }), false, "has:magnet rejected");
assert.equal(
pass({ ...video, nip71: { duration: 30 } }),
false,
"too short rejected",
);
// bitvid's own kind-30078 videos carry duration TOP-LEVEL — both video types
// must satisfy duration filters (spec: two video kinds, more coming for
// live/shorts per the dev plans).
assert.equal(
pass({ ...video, nip71: undefined, duration: 90 }),
true,
"top-level duration (bitvid kind) matches too",
);
assert.equal(
pass({ ...video, created_at: 1_690_000_000 }),
false,
"outside date range rejected",
);
});
test("sortSearchResults honors views / longest / recent (and defaults to recent)", () => {
const videos = [
{ id: "old-popular", created_at: 100, duration: 10 },
{ id: "new-quiet", created_at: 300, duration: 50 },
{ id: "mid-long", created_at: 200, duration: 500 },
];
const viewsById = { "old-popular": 900, "new-quiet": 5, "mid-long": 40 };
const getViews = (v) => viewsById[v.id];
assert.deepEqual(
sortSearchResults(videos, "views", { getViews }).map((v) => v.id),
["old-popular", "mid-long", "new-quiet"],
);
assert.deepEqual(
sortSearchResults(videos, "longest", { getViews }).map((v) => v.id),
["mid-long", "new-quiet", "old-popular"],
);
assert.deepEqual(
sortSearchResults(videos, "recent", { getViews }).map((v) => v.id),
["new-quiet", "mid-long", "old-popular"],
);
// Unknown/default -> recent; input array is not mutated.
const copy = [...videos];
sortSearchResults(videos, undefined, { getViews });
assert.deepEqual(videos, copy);
});
test("sort: trending weights views by recency", () => {
const now = 1_700_000_000_000; // ms
const daySec = 86400;
const videos = [
{ id: "old-hit", created_at: now / 1000 - 30 * daySec }, // 900 views, 30d old
{ id: "fresh-riser", created_at: now / 1000 - 1 * daySec }, // 60 views, 1d old
];
const getViews = (v) => (v.id === "old-hit" ? 900 : 60);
const ranked = sortSearchResults(videos, "trending", { getViews, now }).map(
(v) => v.id,
);
assert.deepEqual(ranked, ["fresh-riser", "old-hit"]);
});
// Author values arrive as npubs but videos carry hex pubkeys — without decoding,
// npub authors silently matched nothing (the "wired but wrong" filter).
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-search-author-npub
// given: "author values as npub / hex / garbage"
// when: "normalizeAuthorValues runs (with an injected decoder)"
// then: "npubs decode to hex, hex passes through lowercased, garbage is dropped with errors"
// observable_outcomes:
// - "npub -> decoded hex; HEX passthrough lowercased; undecodable -> dropped"
// - "parser author: token emits decode errors for garbage"
// determinism_controls:
// - "injected decodeNpub stub (no window.NostrTools dependency)"
// anti_cheat_rationale:
// prevents: ["hard-coded return value"]
// relaxation:
// did_relax_any_assertion: false
test("normalizeAuthorValues decodes npubs, passes hex, drops garbage", async () => {
const { normalizeAuthorValues } = await import("../js/search/searchFilters.js");
const HEX = "a".repeat(64);
const decodeNpub = (v) => (v === "npub1good" ? HEX : null);
const { normalized, dropped } = normalizeAuthorValues(
["npub1good", "B".repeat(64), "npub1bad", "garbage"],
{ decodeNpub },
);
assert.deepEqual(normalized, [HEX, "b".repeat(64)]);
assert.deepEqual(dropped, ["npub1bad", "garbage"]);
});
test("author: token with hex passes into filters; garbage surfaces as an error", () => {
const HEX = "c".repeat(64);
const good = parseFilterQuery(`author:${HEX}`);
assert.deepEqual(good.filters.authorPubkeys, [HEX]);
const bad = parseFilterQuery("author:not-a-key");
assert.equal(bad.filters.authorPubkeys.length, 0);
assert.ok(bad.errors.some((e) => /decode author/i.test(e.message)));
});