Compare commits
7 Commits
6159ec00e0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7623fff227 | ||
|
|
2105d75cb5 | ||
|
|
e937863bc6 | ||
|
|
5c4b189136 | ||
|
|
764ba09f77 | ||
|
|
365deb305a | ||
|
|
02f3139f46 |
@@ -24,7 +24,7 @@ jobs:
|
||||
run: deno lint
|
||||
|
||||
- name: Type check
|
||||
run: deno check jetstream.ts server.ts scripts/*.ts src/migrations/init.ts
|
||||
run: deno check jetstream.ts server.ts scripts/*.ts src/migrations/init.ts tests/
|
||||
|
||||
- name: Test
|
||||
run: deno test --allow-all --permit-no-files
|
||||
run: deno test --allow-all tests/
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@oak/oak": "jsr:@oak/oak@^17.2.0",
|
||||
"@opensearch-project/opensearch": "npm:@opensearch-project/opensearch@^3.6.0",
|
||||
"@skyware/jetstream": "npm:@skyware/jetstream@^0.2.5",
|
||||
"@std/assert": "jsr:@std/assert@1"
|
||||
"@std/assert": "jsr:@std/assert@1",
|
||||
"@std/testing": "jsr:@std/testing@1"
|
||||
}
|
||||
}
|
||||
|
||||
17
deno.lock
generated
17
deno.lock
generated
@@ -7,8 +7,10 @@
|
||||
"jsr:@oak/commons@1": "1.0.0",
|
||||
"jsr:@oak/oak@^17.2.0": "17.2.0",
|
||||
"jsr:@std/assert@1": "1.0.18",
|
||||
"jsr:@std/async@^1.4.0": "1.4.0",
|
||||
"jsr:@std/bytes@1": "1.0.4",
|
||||
"jsr:@std/crypto@1": "1.0.3",
|
||||
"jsr:@std/data-structures@^1.1.0": "1.1.0",
|
||||
"jsr:@std/encoding@1": "1.0.6",
|
||||
"jsr:@std/encoding@^1.0.5": "1.0.6",
|
||||
"jsr:@std/fmt@1": "1.0.9",
|
||||
@@ -20,6 +22,7 @@
|
||||
"jsr:@std/path@1": "1.1.4",
|
||||
"jsr:@std/path@1.0": "1.0.9",
|
||||
"jsr:@std/path@^1.1.1": "1.1.4",
|
||||
"jsr:@std/testing@1": "1.0.19",
|
||||
"npm:@opensearch-project/opensearch@3": "3.6.0",
|
||||
"npm:@opensearch-project/opensearch@^3.6.0": "3.6.0",
|
||||
"npm:@skyware/jetstream@~0.2.5": "0.2.5",
|
||||
@@ -71,12 +74,18 @@
|
||||
"jsr:@std/internal@^1.0.12"
|
||||
]
|
||||
},
|
||||
"@std/async@1.4.0": {
|
||||
"integrity": "4d70b008634f571cff9b554090d628c76141c32613aae0ff283fd5fa23d0c379"
|
||||
},
|
||||
"@std/bytes@1.0.4": {
|
||||
"integrity": "11a0debe522707c95c7b7ef89b478c13fb1583a7cfb9a85674cd2cc2e3a28abc"
|
||||
},
|
||||
"@std/crypto@1.0.3": {
|
||||
"integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f"
|
||||
},
|
||||
"@std/data-structures@1.1.0": {
|
||||
"integrity": "c35ae4ad5d8e41a38573c2fe3e19b18ea2505f63cfea201edcb8720aca1f7f58"
|
||||
},
|
||||
"@std/encoding@1.0.6": {
|
||||
"integrity": "ca87122c196e8831737d9547acf001766618e78cd8c33920776c7f5885546069"
|
||||
},
|
||||
@@ -110,6 +119,13 @@
|
||||
"dependencies": [
|
||||
"jsr:@std/internal@^1.0.12"
|
||||
]
|
||||
},
|
||||
"@std/testing@1.0.19": {
|
||||
"integrity": "f4236172365b216728dc3cc8b5e80a9f4c33083d1e4ede7613d5b25b4014898e",
|
||||
"dependencies": [
|
||||
"jsr:@std/async",
|
||||
"jsr:@std/data-structures"
|
||||
]
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
@@ -231,6 +247,7 @@
|
||||
"jsr:@db/sqlite@0.13",
|
||||
"jsr:@oak/oak@^17.2.0",
|
||||
"jsr:@std/assert@1",
|
||||
"jsr:@std/testing@1",
|
||||
"npm:@opensearch-project/opensearch@^3.6.0",
|
||||
"npm:@skyware/jetstream@~0.2.5"
|
||||
]
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import type { API } from "@opensearch-project/opensearch";
|
||||
import { log } from "../log.ts";
|
||||
|
||||
const OPENSEARCH_URL = Deno.env.get("OPENSEARCH_URL");
|
||||
const OPENSEARCH_USERNAME = Deno.env.get("OPENSEARCH_USERNAME");
|
||||
const OPENSEARCH_PASSWORD = Deno.env.get("OPENSEARCH_PASSWORD");
|
||||
const OPENSEARCH_INDEX = Deno.env.get("OPENSEARCH_INDEX") ?? "notes";
|
||||
|
||||
const MAX_LIMIT = 100;
|
||||
|
||||
const enabled = (): boolean => {
|
||||
if (!OPENSEARCH_URL) return false;
|
||||
return true;
|
||||
};
|
||||
const openSearchUrl = (): string | undefined => Deno.env.get("OPENSEARCH_URL");
|
||||
const openSearchIndex = (): string =>
|
||||
Deno.env.get("OPENSEARCH_INDEX") ?? "notes";
|
||||
|
||||
const enabled = (): boolean => Boolean(openSearchUrl());
|
||||
|
||||
const authHeaders = (): Record<string, string> => {
|
||||
if (OPENSEARCH_USERNAME && OPENSEARCH_PASSWORD) {
|
||||
const creds = btoa(`${OPENSEARCH_USERNAME}:${OPENSEARCH_PASSWORD}`);
|
||||
const username = Deno.env.get("OPENSEARCH_USERNAME");
|
||||
const password = Deno.env.get("OPENSEARCH_PASSWORD");
|
||||
if (username && password) {
|
||||
const creds = btoa(`${username}:${password}`);
|
||||
return { Authorization: `Basic ${creds}` };
|
||||
}
|
||||
return {};
|
||||
@@ -29,7 +27,7 @@ const request = async (
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<Response> => {
|
||||
const url = `${OPENSEARCH_URL}${path}`;
|
||||
const url = `${openSearchUrl()}${path}`;
|
||||
const headers: Record<string, string> = { ...authHeaders() };
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||
return await fetch(url, {
|
||||
@@ -59,11 +57,12 @@ const indexMappings = {
|
||||
|
||||
export const ensureIndex = async (): Promise<void> => {
|
||||
if (!enabled()) return;
|
||||
const head = await request("HEAD", `/${OPENSEARCH_INDEX}`);
|
||||
const index = openSearchIndex();
|
||||
const head = await request("HEAD", `/${index}`);
|
||||
if (head.status === 200) return;
|
||||
const res = await request("PUT", `/${OPENSEARCH_INDEX}`, indexMappings);
|
||||
const res = await request("PUT", `/${index}`, indexMappings);
|
||||
if (res.ok) {
|
||||
log(`[opensearch] created index ${OPENSEARCH_INDEX}`);
|
||||
log(`[opensearch] created index ${index}`);
|
||||
return;
|
||||
}
|
||||
const text = await res.text();
|
||||
@@ -96,7 +95,7 @@ export const indexNote = async (note: IndexedNote): Promise<void> => {
|
||||
};
|
||||
const res = await request(
|
||||
"PUT",
|
||||
`/${OPENSEARCH_INDEX}/_doc/${docId(note.did, note.rkey)}`,
|
||||
`/${openSearchIndex()}/_doc/${docId(note.did, note.rkey)}`,
|
||||
doc,
|
||||
);
|
||||
if (!res.ok) {
|
||||
@@ -114,7 +113,7 @@ export const removeNote = async (
|
||||
if (!enabled()) return;
|
||||
const res = await request(
|
||||
"DELETE",
|
||||
`/${OPENSEARCH_INDEX}/_doc/${docId(did, rkey)}`,
|
||||
`/${openSearchIndex()}/_doc/${docId(did, rkey)}`,
|
||||
);
|
||||
if (!res.ok && res.status !== 404) {
|
||||
const text = await res.text();
|
||||
@@ -219,7 +218,7 @@ export const searchNotes = async (
|
||||
...(searchAfter ? { search_after: searchAfter } : {}),
|
||||
};
|
||||
|
||||
const res = await request("POST", `/${OPENSEARCH_INDEX}/_search`, body);
|
||||
const res = await request("POST", `/${openSearchIndex()}/_search`, body);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Search failed: ${res.status} ${text}`);
|
||||
|
||||
@@ -52,7 +52,7 @@ Deno.test(
|
||||
`Bearer ${makeJwt({ sub: did })}`,
|
||||
);
|
||||
assertEquals(result, did);
|
||||
const sessionCall = stub.calls.find((c) => c.url.includes("/getSession"));
|
||||
const sessionCall = stub.calls.find((c) => c.url.endsWith("getSession"));
|
||||
assertEquals(
|
||||
sessionCall?.headers.get("Authorization")?.startsWith("Bearer "),
|
||||
true,
|
||||
|
||||
147
tests/jetstream_bulk_test.ts
Normal file
147
tests/jetstream_bulk_test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { assertEquals } from "@std/assert";
|
||||
import { FakeTime } from "@std/testing/time";
|
||||
import {
|
||||
_resetBulkBuffersForTest,
|
||||
type BulkDeps,
|
||||
queueBulkCreate,
|
||||
} from "../src/jetstream/bulk.ts";
|
||||
import type { WebhookTarget } from "../src/jetstream/webhooks.ts";
|
||||
import type { WebhookVerb } from "../src/data/db.ts";
|
||||
|
||||
type DispatchCall = {
|
||||
webhooks: WebhookTarget[];
|
||||
payload: Record<string, unknown>;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const flushMicrotasks = async (): Promise<void> => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const makeDeps = (
|
||||
webhooks: WebhookTarget[] = [],
|
||||
): {
|
||||
deps: BulkDeps;
|
||||
getSubsCalls: { did: string; verb: WebhookVerb }[];
|
||||
dispatchCalls: DispatchCall[];
|
||||
} => {
|
||||
const getSubsCalls: { did: string; verb: WebhookVerb }[] = [];
|
||||
const dispatchCalls: DispatchCall[] = [];
|
||||
const deps: BulkDeps = {
|
||||
getSubs: (did, verb) => {
|
||||
getSubsCalls.push({ did, verb });
|
||||
return webhooks;
|
||||
},
|
||||
dispatch: (w, payload, label) => {
|
||||
dispatchCalls.push({ webhooks: w, payload, label });
|
||||
return Promise.resolve();
|
||||
},
|
||||
debounceMs: 500,
|
||||
};
|
||||
return { deps, getSubsCalls, dispatchCalls };
|
||||
};
|
||||
|
||||
Deno.test("queueBulkCreate accumulates records and flushes once after debounce", async () => {
|
||||
const time = new FakeTime();
|
||||
try {
|
||||
_resetBulkBuffersForTest();
|
||||
const { deps, dispatchCalls } = makeDeps();
|
||||
|
||||
queueBulkCreate("did:plc:a", { rkey: "r1" }, deps);
|
||||
queueBulkCreate("did:plc:a", { rkey: "r2" }, deps);
|
||||
queueBulkCreate("did:plc:a", { rkey: "r3" }, deps);
|
||||
|
||||
assertEquals(dispatchCalls.length, 0);
|
||||
|
||||
await time.tickAsync(500);
|
||||
await flushMicrotasks();
|
||||
|
||||
assertEquals(dispatchCalls.length, 1);
|
||||
const payload = dispatchCalls[0].payload as {
|
||||
records: { rkey: string }[];
|
||||
};
|
||||
assertEquals(payload.records.map((r) => r.rkey), ["r1", "r2", "r3"]);
|
||||
} finally {
|
||||
_resetBulkBuffersForTest();
|
||||
time.restore();
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("queueBulkCreate restarts the timer on each push (debounce)", async () => {
|
||||
const time = new FakeTime();
|
||||
try {
|
||||
_resetBulkBuffersForTest();
|
||||
const { deps, dispatchCalls } = makeDeps();
|
||||
|
||||
queueBulkCreate("did:plc:a", { rkey: "r1" }, deps);
|
||||
await time.tickAsync(400);
|
||||
queueBulkCreate("did:plc:a", { rkey: "r2" }, deps);
|
||||
await time.tickAsync(400);
|
||||
|
||||
assertEquals(dispatchCalls.length, 0);
|
||||
|
||||
await time.tickAsync(100);
|
||||
await flushMicrotasks();
|
||||
|
||||
assertEquals(dispatchCalls.length, 1);
|
||||
} finally {
|
||||
_resetBulkBuffersForTest();
|
||||
time.restore();
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("flushBulkCreate uses bulk-create verb and the standard payload shape", async () => {
|
||||
const time = new FakeTime();
|
||||
try {
|
||||
_resetBulkBuffersForTest();
|
||||
const targets: WebhookTarget[] = [
|
||||
{ method: "POST", url: "https://hook.example/bulk" },
|
||||
];
|
||||
const { deps, getSubsCalls, dispatchCalls } = makeDeps(targets);
|
||||
|
||||
queueBulkCreate("did:plc:a", { rkey: "r1", title: "T" }, deps);
|
||||
await time.tickAsync(500);
|
||||
await flushMicrotasks();
|
||||
|
||||
assertEquals(getSubsCalls, [{ did: "did:plc:a", verb: "bulk-create" }]);
|
||||
assertEquals(dispatchCalls.length, 1);
|
||||
assertEquals(dispatchCalls[0].webhooks, targets);
|
||||
assertEquals(dispatchCalls[0].label, "bulk-create did:plc:a");
|
||||
assertEquals(dispatchCalls[0].payload, {
|
||||
event: "bulk-create",
|
||||
did: "did:plc:a",
|
||||
records: [{ rkey: "r1", title: "T" }],
|
||||
});
|
||||
} finally {
|
||||
_resetBulkBuffersForTest();
|
||||
time.restore();
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("bulkBuffers are cleared after flush and isolated per did", async () => {
|
||||
const time = new FakeTime();
|
||||
try {
|
||||
_resetBulkBuffersForTest();
|
||||
const { deps, dispatchCalls } = makeDeps();
|
||||
|
||||
queueBulkCreate("did:plc:a", { rkey: "a1" }, deps);
|
||||
queueBulkCreate("did:plc:b", { rkey: "b1" }, deps);
|
||||
await time.tickAsync(500);
|
||||
await flushMicrotasks();
|
||||
|
||||
assertEquals(dispatchCalls.length, 2);
|
||||
const dids = dispatchCalls.map((c) => (c.payload as { did: string }).did)
|
||||
.sort();
|
||||
assertEquals(dids, ["did:plc:a", "did:plc:b"]);
|
||||
|
||||
// Another flush window should not re-dispatch the same records.
|
||||
await time.tickAsync(500);
|
||||
await flushMicrotasks();
|
||||
assertEquals(dispatchCalls.length, 2);
|
||||
} finally {
|
||||
_resetBulkBuffersForTest();
|
||||
time.restore();
|
||||
}
|
||||
});
|
||||
94
tests/jetstream_webhooks_test.ts
Normal file
94
tests/jetstream_webhooks_test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { assertEquals } from "@std/assert";
|
||||
import { dispatchAll } from "../src/jetstream/webhooks.ts";
|
||||
import { installFetchStub, jsonResponse } from "./_helpers/fetch_stub.ts";
|
||||
|
||||
Deno.test("dispatchAll is a noop on empty webhook list", async () => {
|
||||
const stub = installFetchStub(() =>
|
||||
jsonResponse({ should: "not be called" })
|
||||
);
|
||||
try {
|
||||
await dispatchAll([], { event: "create" }, "label");
|
||||
assertEquals(stub.calls.length, 0);
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test(
|
||||
"dispatchAll sends Authorization Bearer only when token is present",
|
||||
async () => {
|
||||
const stub = installFetchStub(() => new Response(null, { status: 204 }));
|
||||
try {
|
||||
await dispatchAll(
|
||||
[
|
||||
{ method: "POST", url: "https://a.example/hook" },
|
||||
{
|
||||
method: "POST",
|
||||
url: "https://b.example/hook",
|
||||
token: "shh",
|
||||
},
|
||||
],
|
||||
{ event: "create" },
|
||||
"label",
|
||||
);
|
||||
assertEquals(stub.calls.length, 2);
|
||||
const [first, second] = stub.calls;
|
||||
assertEquals(first.headers.get("Authorization"), null);
|
||||
assertEquals(second.headers.get("Authorization"), "Bearer shh");
|
||||
assertEquals(first.headers.get("Content-Type"), "application/json");
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Deno.test(
|
||||
"dispatchAll omits body and Content-Type for GET and HEAD",
|
||||
async () => {
|
||||
const stub = installFetchStub(() => new Response(null, { status: 204 }));
|
||||
try {
|
||||
await dispatchAll(
|
||||
[
|
||||
{ method: "GET", url: "https://a.example/ping" },
|
||||
{ method: "HEAD", url: "https://b.example/ping" },
|
||||
],
|
||||
{ event: "create", data: "ignored" },
|
||||
"label",
|
||||
);
|
||||
assertEquals(stub.calls.length, 2);
|
||||
for (const call of stub.calls) {
|
||||
assertEquals(call.body, "");
|
||||
assertEquals(call.headers.get("Content-Type"), null);
|
||||
}
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Deno.test(
|
||||
"dispatchAll uses Promise.allSettled — one rejection does not abort others",
|
||||
async () => {
|
||||
const stub = installFetchStub((req) => {
|
||||
if (req.url === "https://reject.example/hook") {
|
||||
return Promise.reject(new Error("connection refused"));
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
});
|
||||
try {
|
||||
await dispatchAll(
|
||||
[
|
||||
{ method: "POST", url: "https://reject.example/hook" },
|
||||
{ method: "POST", url: "https://ok.example/hook" },
|
||||
],
|
||||
{ event: "create" },
|
||||
"label",
|
||||
);
|
||||
assertEquals(stub.calls.length, 2);
|
||||
const urls = stub.calls.map((c) => c.url);
|
||||
assertEquals(urls.includes("https://ok.example/hook"), true);
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
},
|
||||
);
|
||||
204
tests/opensearch_test.ts
Normal file
204
tests/opensearch_test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { assertEquals, assertRejects } from "@std/assert";
|
||||
import {
|
||||
decodeCursor,
|
||||
encodeCursor,
|
||||
firstSnippet,
|
||||
searchNotes,
|
||||
} from "../src/search/opensearch.ts";
|
||||
import { installFetchStub, jsonResponse } from "./_helpers/fetch_stub.ts";
|
||||
|
||||
const withOpenSearchEnv = (fn: () => Promise<void> | void) => {
|
||||
return async () => {
|
||||
const previousUrl = Deno.env.get("OPENSEARCH_URL");
|
||||
const previousIndex = Deno.env.get("OPENSEARCH_INDEX");
|
||||
Deno.env.set("OPENSEARCH_URL", "http://opensearch.test");
|
||||
Deno.env.set("OPENSEARCH_INDEX", "notes");
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
if (previousUrl === undefined) Deno.env.delete("OPENSEARCH_URL");
|
||||
else Deno.env.set("OPENSEARCH_URL", previousUrl);
|
||||
if (previousIndex === undefined) Deno.env.delete("OPENSEARCH_INDEX");
|
||||
else Deno.env.set("OPENSEARCH_INDEX", previousIndex);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
Deno.test("encodeCursor and decodeCursor round-trip", () => {
|
||||
const cursor = encodeCursor(3.14, "abc123");
|
||||
assertEquals(decodeCursor(cursor), [3.14, "abc123"]);
|
||||
});
|
||||
|
||||
Deno.test("decodeCursor returns undefined on garbage input", () => {
|
||||
assertEquals(decodeCursor("not-base64-at-all!!!"), undefined);
|
||||
assertEquals(decodeCursor(btoa("not-json")), undefined);
|
||||
assertEquals(decodeCursor(btoa(JSON.stringify([1]))), undefined);
|
||||
assertEquals(
|
||||
decodeCursor(btoa(JSON.stringify(["wrong", "type"]))),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("firstSnippet prefers highlight.content > title > source content > title fallback", () => {
|
||||
assertEquals(
|
||||
firstSnippet(
|
||||
{ content: ["hl-content"], title: ["hl-title"] },
|
||||
{ title: "src-title", content: "src-content" },
|
||||
),
|
||||
"hl-content",
|
||||
);
|
||||
assertEquals(
|
||||
firstSnippet({ title: ["hl-title"] }, {
|
||||
title: "src-title",
|
||||
content: "src-content",
|
||||
}),
|
||||
"hl-title",
|
||||
);
|
||||
const longContent = "a".repeat(500);
|
||||
assertEquals(
|
||||
firstSnippet(undefined, { content: longContent, title: "ignored" }).length,
|
||||
200,
|
||||
);
|
||||
assertEquals(firstSnippet(undefined, { title: "only-title" }), "only-title");
|
||||
});
|
||||
|
||||
Deno.test(
|
||||
"searchNotes returns empty when OPENSEARCH_URL is unset",
|
||||
async () => {
|
||||
const previousUrl = Deno.env.get("OPENSEARCH_URL");
|
||||
Deno.env.delete("OPENSEARCH_URL");
|
||||
try {
|
||||
const stub = installFetchStub(() =>
|
||||
jsonResponse({ should: "not be called" })
|
||||
);
|
||||
try {
|
||||
const result = await searchNotes({
|
||||
q: "hello",
|
||||
discoverableOnly: true,
|
||||
limit: 10,
|
||||
});
|
||||
assertEquals(result, { results: [] });
|
||||
assertEquals(stub.calls.length, 0);
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
} finally {
|
||||
if (previousUrl !== undefined) {
|
||||
Deno.env.set("OPENSEARCH_URL", previousUrl);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Deno.test(
|
||||
"searchNotes builds multi_match body with filters and cursor",
|
||||
withOpenSearchEnv(async () => {
|
||||
const stub = installFetchStub(() =>
|
||||
jsonResponse({
|
||||
hits: {
|
||||
hits: [
|
||||
{
|
||||
_source: {
|
||||
did: "did:plc:a",
|
||||
rkey: "rk-a",
|
||||
title: "T",
|
||||
content: "c",
|
||||
},
|
||||
_score: 1.5,
|
||||
highlight: { content: ["snippet"] },
|
||||
},
|
||||
{
|
||||
_source: {
|
||||
did: "did:plc:b",
|
||||
rkey: "rk-b",
|
||||
title: "U",
|
||||
content: "d",
|
||||
},
|
||||
_score: 1.0,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
try {
|
||||
const result = await searchNotes({
|
||||
q: "hello world",
|
||||
discoverableOnly: true,
|
||||
did: "did:plc:a",
|
||||
limit: 2,
|
||||
cursor: encodeCursor(2.0, "rk-prev"),
|
||||
});
|
||||
assertEquals(result.results.length, 2);
|
||||
assertEquals(result.results[0].snippet, "snippet");
|
||||
assertEquals(result.results[1].snippet, "d");
|
||||
assertEquals(result.cursor, encodeCursor(1.0, "rk-b"));
|
||||
|
||||
assertEquals(stub.calls.length, 1);
|
||||
const sent = JSON.parse(stub.calls[0].body) as Record<string, unknown>;
|
||||
assertEquals(sent.size, 2);
|
||||
assertEquals(sent.search_after, [2.0, "rk-prev"]);
|
||||
const query = sent.query as {
|
||||
bool: { must: unknown[]; filter: unknown[] };
|
||||
};
|
||||
const multi =
|
||||
(query.bool.must[0] as { multi_match: Record<string, unknown> })
|
||||
.multi_match;
|
||||
assertEquals(multi.query, "hello world");
|
||||
assertEquals(multi.fields, ["title^2", "content"]);
|
||||
assertEquals(multi.fuzziness, "AUTO");
|
||||
assertEquals(query.bool.filter, [
|
||||
{ term: { discoverable: true } },
|
||||
{ term: { did: "did:plc:a" } },
|
||||
]);
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Deno.test(
|
||||
"searchNotes returns cursor only when the page is full",
|
||||
withOpenSearchEnv(async () => {
|
||||
const stub = installFetchStub(() =>
|
||||
jsonResponse({
|
||||
hits: {
|
||||
hits: [
|
||||
{
|
||||
_source: { did: "did:plc:a", rkey: "rk", title: "T" },
|
||||
_score: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
try {
|
||||
const result = await searchNotes({
|
||||
q: "hi",
|
||||
discoverableOnly: false,
|
||||
limit: 5,
|
||||
});
|
||||
assertEquals(result.results.length, 1);
|
||||
assertEquals(result.cursor, undefined);
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Deno.test(
|
||||
"searchNotes throws on non-ok response",
|
||||
withOpenSearchEnv(async () => {
|
||||
const stub = installFetchStub(() => new Response("boom", { status: 500 }));
|
||||
try {
|
||||
await assertRejects(() =>
|
||||
searchNotes({
|
||||
q: "hi",
|
||||
discoverableOnly: false,
|
||||
limit: 5,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
}),
|
||||
);
|
||||
287
tests/server_test.ts
Normal file
287
tests/server_test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { assertEquals } from "@std/assert";
|
||||
import { createApp } from "../server.ts";
|
||||
import { addWebhookSubscription, listWebhooksByDid } from "../src/data/db.ts";
|
||||
import { seedNote, withTestDb } from "./_helpers/db.ts";
|
||||
import { requestApp } from "./_helpers/app.ts";
|
||||
import { stubAuthenticate } from "./_helpers/auth.ts";
|
||||
import { installFetchStub, jsonResponse } from "./_helpers/fetch_stub.ts";
|
||||
|
||||
const withAdminEnv = (
|
||||
dids: string,
|
||||
fn: () => Promise<void> | void,
|
||||
): () => Promise<void> => {
|
||||
return async () => {
|
||||
const previous = Deno.env.get("ADMIN_DIDS");
|
||||
Deno.env.set("ADMIN_DIDS", dids);
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
if (previous === undefined) Deno.env.delete("ADMIN_DIDS");
|
||||
else Deno.env.set("ADMIN_DIDS", previous);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const withOpenSearchEnv = (fn: () => Promise<void> | void) => {
|
||||
return async () => {
|
||||
const previous = Deno.env.get("OPENSEARCH_URL");
|
||||
Deno.env.set("OPENSEARCH_URL", "http://opensearch.test");
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
if (previous === undefined) Deno.env.delete("OPENSEARCH_URL");
|
||||
else Deno.env.set("OPENSEARCH_URL", previous);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
Deno.test(
|
||||
"GET /health returns 200 with status ok",
|
||||
withTestDb(async () => {
|
||||
const res = await requestApp(createApp(), { path: "/health" });
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.json(), { status: "ok" });
|
||||
}),
|
||||
);
|
||||
|
||||
Deno.test(
|
||||
"GET /notes paginates rkey desc and honors cursor + limit",
|
||||
withTestDb(async () => {
|
||||
for (const rkey of ["a", "b", "c", "d", "e"]) {
|
||||
seedNote({ rkey });
|
||||
}
|
||||
const app = createApp();
|
||||
|
||||
const first = await requestApp(app, { path: "/notes?limit=3" });
|
||||
assertEquals(first.status, 200);
|
||||
const firstBody = first.json() as {
|
||||
notes: { rkey: string }[];
|
||||
cursor?: string;
|
||||
};
|
||||
assertEquals(firstBody.notes.map((n) => n.rkey), ["e", "d", "c"]);
|
||||
assertEquals(firstBody.cursor, "c");
|
||||
|
||||
const second = await requestApp(app, {
|
||||
path: `/notes?limit=3&cursor=${firstBody.cursor}`,
|
||||
});
|
||||
const secondBody = second.json() as { notes: { rkey: string }[] };
|
||||
assertEquals(secondBody.notes.map((n) => n.rkey), ["b", "a"]);
|
||||
}),
|
||||
);
|
||||
|
||||
Deno.test(
|
||||
"POST /notes/feed rejects empty dids and accepts a valid list",
|
||||
withTestDb(async () => {
|
||||
seedNote({ did: "did:plc:alice", rkey: "rk1" });
|
||||
const app = createApp();
|
||||
|
||||
const empty = await requestApp(app, {
|
||||
method: "POST",
|
||||
path: "/notes/feed",
|
||||
body: { dids: [] },
|
||||
});
|
||||
assertEquals(empty.status, 400);
|
||||
|
||||
const notArray = await requestApp(app, {
|
||||
method: "POST",
|
||||
path: "/notes/feed",
|
||||
body: { dids: "did:plc:alice" },
|
||||
});
|
||||
assertEquals(notArray.status, 400);
|
||||
|
||||
const ok = await requestApp(app, {
|
||||
method: "POST",
|
||||
path: "/notes/feed",
|
||||
body: { dids: ["did:plc:alice"] },
|
||||
});
|
||||
assertEquals(ok.status, 200);
|
||||
const body = ok.json() as { notes: { rkey: string }[] };
|
||||
assertEquals(body.notes.length, 1);
|
||||
}),
|
||||
);
|
||||
|
||||
Deno.test(
|
||||
"POST /:did/webhooks: 401 without auth, 403 on DID mismatch, 201 inserts create+delete by default",
|
||||
withTestDb(async () => {
|
||||
const app = createApp();
|
||||
|
||||
const noAuth = await requestApp(app, {
|
||||
method: "POST",
|
||||
path: "/did:plc:alice/webhooks",
|
||||
body: { method: "POST", url: "https://hook.example/x" },
|
||||
});
|
||||
assertEquals(noAuth.status, 401);
|
||||
|
||||
const mismatch = stubAuthenticate("did:plc:bob");
|
||||
try {
|
||||
const res = await requestApp(app, {
|
||||
method: "POST",
|
||||
path: "/did:plc:alice/webhooks",
|
||||
headers: { Authorization: "Bearer fake" },
|
||||
body: { method: "POST", url: "https://hook.example/x" },
|
||||
});
|
||||
assertEquals(res.status, 403);
|
||||
} finally {
|
||||
mismatch.restore();
|
||||
}
|
||||
|
||||
const owned = stubAuthenticate("did:plc:alice");
|
||||
try {
|
||||
const res = await requestApp(app, {
|
||||
method: "POST",
|
||||
path: "/did:plc:alice/webhooks",
|
||||
headers: { Authorization: "Bearer fake" },
|
||||
body: { method: "POST", url: "https://hook.example/x" },
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
const created = res.json() as { verb: string }[];
|
||||
const verbs = created.map((w) => w.verb).sort();
|
||||
assertEquals(verbs, ["create", "delete"]);
|
||||
} finally {
|
||||
owned.restore();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Deno.test(
|
||||
"POST /:did/webhooks rejects unknown verb with 400",
|
||||
withTestDb(async () => {
|
||||
const auth = stubAuthenticate("did:plc:alice");
|
||||
try {
|
||||
const res = await requestApp(createApp(), {
|
||||
method: "POST",
|
||||
path: "/did:plc:alice/webhooks",
|
||||
headers: { Authorization: "Bearer fake" },
|
||||
body: {
|
||||
method: "POST",
|
||||
url: "https://hook.example/x",
|
||||
verb: "destroy",
|
||||
},
|
||||
});
|
||||
assertEquals(res.status, 400);
|
||||
} finally {
|
||||
auth.restore();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Deno.test(
|
||||
"DELETE /:did/webhooks/:id: 400 on non-positive, 404 on unknown, 204 on success",
|
||||
withTestDb(async () => {
|
||||
const created = addWebhookSubscription({
|
||||
did: "did:plc:alice",
|
||||
method: "POST",
|
||||
url: "https://hook.example/x",
|
||||
verb: "create",
|
||||
});
|
||||
const auth = stubAuthenticate("did:plc:alice");
|
||||
const app = createApp();
|
||||
try {
|
||||
const bad = await requestApp(app, {
|
||||
method: "DELETE",
|
||||
path: "/did:plc:alice/webhooks/0",
|
||||
headers: { Authorization: "Bearer fake" },
|
||||
});
|
||||
assertEquals(bad.status, 400);
|
||||
|
||||
const missing = await requestApp(app, {
|
||||
method: "DELETE",
|
||||
path: "/did:plc:alice/webhooks/9999",
|
||||
headers: { Authorization: "Bearer fake" },
|
||||
});
|
||||
assertEquals(missing.status, 404);
|
||||
|
||||
const ok = await requestApp(app, {
|
||||
method: "DELETE",
|
||||
path: `/did:plc:alice/webhooks/${created.id}`,
|
||||
headers: { Authorization: "Bearer fake" },
|
||||
});
|
||||
assertEquals(ok.status, 204);
|
||||
assertEquals(listWebhooksByDid("did:plc:alice").length, 0);
|
||||
} finally {
|
||||
auth.restore();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Deno.test(
|
||||
"GET /admin/webhooks: 403 for non-admin, 200 when ADMIN_DIDS contains caller",
|
||||
withAdminEnv(
|
||||
"did:plc:admin",
|
||||
withTestDb(async () => {
|
||||
const app = createApp();
|
||||
|
||||
const nonAdmin = stubAuthenticate("did:plc:other");
|
||||
try {
|
||||
const res = await requestApp(app, {
|
||||
path: "/admin/webhooks",
|
||||
headers: { Authorization: "Bearer fake" },
|
||||
});
|
||||
assertEquals(res.status, 403);
|
||||
} finally {
|
||||
nonAdmin.restore();
|
||||
}
|
||||
|
||||
const admin = stubAuthenticate("did:plc:admin");
|
||||
try {
|
||||
const res = await requestApp(app, {
|
||||
path: "/admin/webhooks",
|
||||
headers: { Authorization: "Bearer fake" },
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(Array.isArray(res.json()), true);
|
||||
} finally {
|
||||
admin.restore();
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
Deno.test(
|
||||
"GET /search: 400 on blank q, forwards discoverableOnly:true to opensearch",
|
||||
withOpenSearchEnv(
|
||||
withTestDb(async () => {
|
||||
const app = createApp();
|
||||
|
||||
const blank = await requestApp(app, { path: "/search?q=" });
|
||||
assertEquals(blank.status, 400);
|
||||
|
||||
const stub = installFetchStub(() => jsonResponse({ hits: { hits: [] } }));
|
||||
try {
|
||||
const res = await requestApp(app, { path: "/search?q=hello" });
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(stub.calls.length, 1);
|
||||
const sent = JSON.parse(stub.calls[0].body) as {
|
||||
query: { bool: { filter: Record<string, unknown>[] } };
|
||||
};
|
||||
assertEquals(sent.query.bool.filter, [
|
||||
{ term: { discoverable: true } },
|
||||
]);
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
Deno.test(
|
||||
"OPTIONS preflight returns 204 with CORS headers",
|
||||
withTestDb(async () => {
|
||||
const res = await requestApp(createApp(), {
|
||||
method: "OPTIONS",
|
||||
path: "/notes",
|
||||
});
|
||||
assertEquals(res.status, 204);
|
||||
assertEquals(res.headers.get("Access-Control-Allow-Origin"), "*");
|
||||
assertEquals(
|
||||
res.headers.get("Access-Control-Allow-Methods")?.includes("POST"),
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
res.headers.get("Access-Control-Allow-Headers")?.includes(
|
||||
"Authorization",
|
||||
),
|
||||
true,
|
||||
);
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user