diff --git a/.env.ci b/.env.ci
index 0a3c7562..88ee38be 100644
--- a/.env.ci
+++ b/.env.ci
@@ -1,6 +1,6 @@
+DATABASE_URL=postgresql://postgres:password@localhost:5432/hackerspub
MODE=development
ORIGIN=http://localhost/
-DATABASE_URL=postgresql://localhost/hackerspub
KV_URL=file:///tmp/kv.db
SECRET_KEY=SecretKeyForUnitTests
INSTANCE_ACTOR_KEY='{"kty":"RSA","alg":"RS256","n":"2fG1bUonD7dhunje36058uoNxW_CKREX1pd-63mRoYo-4XbLCyLqTbqy1ar-gdYNqd8OUhqjYXLvjRtS36N3DBb8zhIHmNvMFCMCVaX2hBp_kZhwBPch9Nk2g84rukVSOzjARaN6BhotlvJJ8RfHbTQHJnTIrgRZ5h9nifBfwjfCkS0S3plJfXzHo4XjlF5oTHRfvjM1sOGvsC4G9jZXgLP4MVgOZi3ry3NoYIhtnczk3lt7NWMtr37NK9jxXItXgo9juy-R88C8NPgq6wtuWr_fbMoERyq6wNG05MofnDAgk73PDid47fNkIueybgbFo8dJo68IcgMF8TiDeASp7sOl8TTpxlwOUtbtIGDB8FQDblVw16K7VAOD-ZEng0L_bmeDKf8VZ3WtH95QeBe_2JVPNecuut3NkHy4quWt6g4AujG08pDHWQUwT8ivyqsbBjxRAlqf1_8mcn_1EHnjuC_kuNYZ-lN9IuPk40hChbQpU8kLPTe0nx0JOSvTLumJT0ElDG0WJTOMJnB85B6Q68IiqTXMsKK3dWJWlstZ2Q6CHhAnxd1OyedS3Uq0fcynaePvD2AaA18fuxnTpM4XeFrLxuZ9DjuNLbIdkZMGQxB-o76eefHR1BTuz_sulFAL6Tapvn-P92A2XuGgL6x104MlLTpyOg9z3isFQOjsKTc","e":"AQAB","d":"W7APJnpCsp7wc1V0Vd93eJU00_HvWf2B7NpxH7lWJgKV755dT0MSFT9NCwzT42Vc_DEFwk7Imy-STefH1qPbam-lTUBSh-E4GuSbj9KxQeQv9N2Pitc5JtvWzl6HEOcm8Bkw8lsS88xBj7ZAmzfowy1XMLuCzYqxE1n04hgJARkaNp8iyBjuzOcYydeC5aFN-ZQfz1GV7eMUWWcTsLMt3sxXxtKHxD7fawkTSUE79F4FjvInhE9EIgeTYzXyXH-Wgiu2xkH-AfD8fMBGtflqJK2B3i-i2xnD1Wij1HigEcJBgkYg_JBolJqHbxPQge9BaGRnu3_gaq6Q6mlfDbfQkQZflNn0fCcp49D6YIcGD_1swObr8XDX7rgjSS5WmzMRQbNZsL6qCMohi0h65zcZk9ZUfnwX7vLKWjKTUfpF2uZqUOn_q1DsLMBDCu4ayFQPxA4C2GDlnR4stVFaWRtjNTNmiYWf7nO3H8PgoJHYpQt-vdn0i946INGiceJK5hhFkyiidh1ZJHgfPumOJeEDxBLlmOwaWHsqmplh5yq5RSCN1qpOqzGOJOop_RrHrZ5h9rRR1A4IQOFUE-8ivo8Mg4e6eLNDbLSSsQvRfgv47rbH5cP8LOzY4q9J3Kupl5a7df3Ar7FyyOAR3-QyzXajWLyiy0CJw4k1HtasCK6yBsE","p":"55S8olIxe8x8tu7DK0TpnDi-3bAR3jcetuZX4oSaTE_xu8kpl_TqALuecmswYHQA3IZ2i9rR0uIJHyeQuvHp0lTNhs-fwxIlnJTLXLZmNcwIVe5z2CMaL80WEBFIEG_6J4KNtt13Mg8h56Ij5d7Ry0NpVYEQCDege291LPN7x_cKf8jbdsRRFVckv9nZz8kQ6Prhn3kALV-PAxCum_sl07iz-YNZn0gRnYhP8pWddahdtUQeo_mCH__eHmIJAmpJsxufLgPXH9v0btuXhLz4P5pRO533wpmzE0t7U9pisPnMdi9NIssHyM0p6fEhPd87YBq8VMjkgYhXukYY0ah5YQ","q":"8Ozcf5BUMJnU6Ov5kQ2Ap6W1g_Ig_JOWonAkDHlHo0ElEcHRmkC5SeA7PCsbaEqGVq8Oqi6x90UaODedy-qSzjKx5i0FLKmDjb_4B4fLVFrUST6cQQu7bPcvWRRP2NyAN1AZrt0Y_uSbhIHFVOouHoMqVfQj0aTDK-sp1lPmPn9g7wch8y3Lc9iqcIgn43wy67XJtP5AYdplJrs4JuXYrZtbsfXsRdA2PO6mcazetIBVMETkuKIGF7gEGTI_8JpM23Y_ykM7XFwEhx4ecJtKw0-1e57w-8pt-nlfIbRNQDkJwKX-w6J5z6J6VgFAWp4jvSG15qoC5gU51xHkm_Exlw","dp":"V_jor8EJiz3jIpsRCR7kn7PuzchVAVVvFYvrVuGIu_Sin_OLGW6wdhbP2idd-UYYDa4G2poFm1bCoFMnZ9z-NiiA6vV0e2YpY5IirtWbflRD1mD_INw01nPSLchi19ux69BshUscPKgC-Tte57P9fnndSd39eSGolTuCB9F29D-kfWaP-E4bfz_bdgYL-CMpiVfE3g_ZQWNLsJ5ltltxwzwnImIDab628mEV_dFYP5n1_yYhfakLBZzthB05zvERjjiv_4r17eRgtrw0kvg2VfMJaNxZglNg87N45iHP2-sJANx3MQBvtJg7k-NF_XsP0zJU2OB37b0dlmWKiBq4AQ","dq":"BDSeRLvIPHDy_n3gBWAu9r41xO_dE4uf_YXnmzAix_7DHuQ7PG4Uze1UG2DFQbTLU4gKwX2_LYnPQ1v1LTITDfZklJgElxr-aOMI-Vite_N58S1enOQPiX9nHC72ldqDgnOrfxns7cYf0NhTEYBk_bNccdOpLGer4IBiYpNkWYLvtjxxo0URYBxQHVbDG313hhXOR5KInSyqx1pNSKCKf71OhHS-gxl3WOjxjtptqMj0s7sAIxjw8kkMCUSPSSvoW4xc4LL7vkj86z7jWSPc0jv59wZ3Pm3yLYUg2_3Bu3VDblF3eQLFDZLQf9_Vt7868Ho-KqCdHFbLA64UR4SWUQ","qi":"u1riaXXLRX6mnhMPbnxOBZDUbjqDv5Sq-4WawFWDQuiZvEn_Vp52tgVV7Ra9-kEM4CHTLdo2MZfLizjAXyDMrSWuDw8G_cVy8G75mg7c41s-TUSC-7ioqzbVvOkMDuZ_KoNqDLiDos3GT8XFXxVQcscU37Qo31L4usE2Y3mmlZfpm_GqGGh4rwkiWxjXrr5VtypehFZFRf_mbY8dLBJW-wF2f1uisUp8xh_s9cG2PPnzVO8Gi0V4Od2fK3OTky4SCp-mRqvbll94DCrFyS07P9_35K2k64wgKNVeg57n-1ByNPRQS5NjKbtMVUHX0Qnk2YawD3gQXb2IFhX4S8Gvzg","key_ops":["sign"],"ext":true}'
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index e8f883fb..7fe78afd 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -10,6 +10,20 @@ on:
jobs:
test:
runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: password
+ POSTGRES_DB: hackerspub
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres -d hackerspub"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
@@ -28,6 +42,8 @@ jobs:
- name: Build web-next
run: pnpm build
working-directory: web-next
+ - name: Run database migrations
+ run: npx @dotenvx/dotenvx run -f .env.ci -- deno task migrate
- name: Run tests
if: always()
run: npx @dotenvx/dotenvx run -f .env.ci -- deno task test
diff --git a/graphql/account.test.ts b/graphql/account.test.ts
new file mode 100644
index 00000000..a7009af7
--- /dev/null
+++ b/graphql/account.test.ts
@@ -0,0 +1,285 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { encodeGlobalID } from "@pothos/plugin-relay";
+import * as vocab from "@fedify/vocab";
+import { execute, parse } from "graphql";
+import { updateAccountData } from "@hackerspub/models/account";
+import { schema } from "./mod.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ makeGuestContext,
+ makeUserContext,
+ toPlainJson,
+ withRollback,
+} from "../test/postgres.ts";
+
+const viewerQuery = parse(`
+ query Viewer {
+ viewer {
+ username
+ name
+ handle
+ }
+ }
+`);
+
+const accountByUsernameQuery = parse(`
+ query AccountByUsername($username: String!) {
+ accountByUsername(username: $username) {
+ username
+ name
+ handle
+ }
+ }
+`);
+
+const invitationTreeQuery = parse(`
+ query InvitationTree {
+ invitationTree {
+ id
+ username
+ name
+ avatarUrl
+ inviterId
+ hidden
+ }
+ }
+`);
+
+const updateAccountMutation = parse(`
+ mutation UpdateAccount($input: UpdateAccountInput!) {
+ updateAccount(input: $input) {
+ account {
+ username
+ bio
+ locales
+ preferAiSummary
+ defaultNoteVisibility
+ defaultShareVisibility
+ }
+ }
+ }
+`);
+
+test("viewer returns the signed-in account and null for guests", async () => {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "viewerquery",
+ name: "Viewer Query",
+ email: "viewerquery@example.com",
+ });
+
+ const signedInResult = await execute({
+ schema,
+ document: viewerQuery,
+ contextValue: makeUserContext(tx, account.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(signedInResult.errors, undefined);
+ assert.deepEqual(
+ toPlainJson(signedInResult.data),
+ {
+ viewer: {
+ username: "viewerquery",
+ name: "Viewer Query",
+ handle: "@viewerquery@localhost",
+ },
+ },
+ );
+
+ const guestResult = await execute({
+ schema,
+ document: viewerQuery,
+ contextValue: makeGuestContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(guestResult.errors, undefined);
+ assert.deepEqual(toPlainJson(guestResult.data), { viewer: null });
+ });
+});
+
+test("accountByUsername returns a local account by username", async () => {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "lookupgraphql",
+ name: "Lookup GraphQL",
+ email: "lookupgraphql@example.com",
+ });
+
+ const result = await execute({
+ schema,
+ document: accountByUsernameQuery,
+ variableValues: { username: account.account.username },
+ contextValue: makeGuestContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+ assert.deepEqual(toPlainJson(result.data), {
+ accountByUsername: {
+ username: "lookupgraphql",
+ name: "Lookup GraphQL",
+ handle: "@lookupgraphql@localhost",
+ },
+ });
+ });
+});
+
+test("invitationTree redacts hidden accounts", async () => {
+ await withRollback(async (tx) => {
+ const visible = await insertAccountWithActor(tx, {
+ username: "visibletree",
+ name: "Visible Tree",
+ email: "visibletree@example.com",
+ });
+ const hidden = await insertAccountWithActor(tx, {
+ username: "hiddentree",
+ name: "Hidden Tree",
+ email: "hiddentree@example.com",
+ });
+
+ const updated = await updateAccountData(tx, {
+ id: hidden.account.id,
+ hideFromInvitationTree: true,
+ });
+ assert.ok(updated != null);
+
+ const result = await execute({
+ schema,
+ document: invitationTreeQuery,
+ contextValue: makeGuestContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+
+ const nodes = (result.data as {
+ invitationTree: Array<{
+ id: string;
+ username: string | null;
+ name: string | null;
+ avatarUrl: string;
+ inviterId: string | null;
+ hidden: boolean;
+ }>;
+ }).invitationTree;
+ const visibleNode = nodes.find((node) => node.id === visible.account.id);
+ const hiddenNode = nodes.find((node) => node.id === hidden.account.id);
+
+ assert.ok(visibleNode != null);
+ assert.ok(hiddenNode != null);
+ assert.equal(visibleNode.hidden, false);
+ assert.equal(visibleNode.username, "visibletree");
+ assert.equal(visibleNode.name, "Visible Tree");
+
+ assert.equal(hiddenNode.hidden, true);
+ assert.equal(hiddenNode.username, null);
+ assert.equal(hiddenNode.name, null);
+ assert.equal(
+ hiddenNode.avatarUrl,
+ "https://gravatar.com/avatar/?d=mp&s=128",
+ );
+ });
+});
+
+test("updateAccount updates profile preferences for the signed-in account", async () => {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "updateaccountgraphql",
+ name: "Update Account GraphQL",
+ email: "updateaccountgraphql@example.com",
+ });
+
+ const fedCtx = createFedCtx(tx);
+ fedCtx.getActor = (identifier: string) =>
+ Promise.resolve(
+ new vocab.Person({
+ id: fedCtx.getActorUri(identifier),
+ }),
+ );
+
+ const result = await execute({
+ schema,
+ document: updateAccountMutation,
+ variableValues: {
+ input: {
+ id: encodeGlobalID("Account", account.account.id),
+ bio: "Updated profile bio",
+ locales: ["ko-KR", "en-US"],
+ preferAiSummary: true,
+ hideFromInvitationTree: true,
+ hideForeignLanguages: true,
+ defaultNoteVisibility: "FOLLOWERS",
+ defaultShareVisibility: "UNLISTED",
+ },
+ },
+ contextValue: makeUserContext(tx, account.account, { fedCtx }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+ assert.deepEqual(toPlainJson(result.data), {
+ updateAccount: {
+ account: {
+ username: "updateaccountgraphql",
+ bio: "Updated profile bio",
+ locales: ["ko-KR", "en-US"],
+ preferAiSummary: true,
+ defaultNoteVisibility: "FOLLOWERS",
+ defaultShareVisibility: "UNLISTED",
+ },
+ },
+ });
+
+ const stored = await tx.query.accountTable.findFirst({
+ where: { id: account.account.id },
+ });
+ assert.ok(stored != null);
+ assert.equal(stored.hideFromInvitationTree, true);
+ assert.equal(stored.hideForeignLanguages, true);
+ assert.deepEqual(stored.locales, ["ko-KR", "en-US"]);
+ assert.equal(stored.preferAiSummary, true);
+ assert.equal(stored.noteVisibility, "followers");
+ assert.equal(stored.shareVisibility, "unlisted");
+ });
+});
+
+test("updateAccount rejects a second username change", async () => {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "renameonce",
+ name: "Rename Once",
+ email: "renameonce@example.com",
+ });
+
+ const renamed = await updateAccountData(tx, {
+ id: account.account.id,
+ username: "renamedonce",
+ });
+ assert.ok(renamed != null);
+ assert.ok(renamed.usernameChanged != null);
+
+ const result = await execute({
+ schema,
+ document: updateAccountMutation,
+ variableValues: {
+ input: {
+ id: encodeGlobalID("Account", account.account.id),
+ username: "renamedtwice",
+ },
+ },
+ contextValue: makeUserContext(tx, { ...account.account, ...renamed }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.deepEqual(toPlainJson(result.data), { updateAccount: null });
+ assert.equal(result.errors?.length, 1);
+ assert.equal(
+ result.errors?.[0].message,
+ "Username cannot be changed after it has been changed.",
+ );
+ });
+});
diff --git a/graphql/actor.more.test.ts b/graphql/actor.more.test.ts
new file mode 100644
index 00000000..4b7a87f2
--- /dev/null
+++ b/graphql/actor.more.test.ts
@@ -0,0 +1,208 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { encodeGlobalID } from "@pothos/plugin-relay";
+import { execute, parse } from "graphql";
+import { follow } from "@hackerspub/models/following";
+import { schema } from "./mod.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ insertNotePost,
+ insertRemoteActor,
+ insertRemotePost,
+ makeGuestContext,
+ makeUserContext,
+ toPlainJson,
+ withRollback,
+} from "../test/postgres.ts";
+
+const actorByUuidQuery = parse(`
+ query ActorByUuid($uuid: UUID!) {
+ actorByUuid(uuid: $uuid) {
+ id
+ handle
+ }
+ }
+`);
+
+const actorByHandleQuery = parse(`
+ query ActorByHandle($handle: String!, $allowLocalHandle: Boolean!) {
+ actorByHandle(handle: $handle, allowLocalHandle: $allowLocalHandle) {
+ id
+ handle
+ }
+ }
+`);
+
+const instanceByHostQuery = parse(`
+ query InstanceByHost($host: String!) {
+ instanceByHost(host: $host) {
+ host
+ software
+ }
+ }
+`);
+
+const searchActorsByHandleQuery = parse(`
+ query SearchActorsByHandle($prefix: String!, $limit: Int!) {
+ searchActorsByHandle(prefix: $prefix, limit: $limit) {
+ handle
+ }
+ }
+`);
+
+const recommendedActorsQuery = parse(`
+ query RecommendedActors($limit: Int!, $locale: Locale) {
+ recommendedActors(limit: $limit, locale: $locale) {
+ handle
+ }
+ }
+`);
+
+test("actorByUuid and actorByHandle resolve local actors", async () => {
+ await withRollback(async (tx) => {
+ const actor = await insertAccountWithActor(tx, {
+ username: "actorlookupgraphql",
+ name: "Actor Lookup GraphQL",
+ email: "actorlookupgraphql@example.com",
+ });
+
+ const byUuid = await execute({
+ schema,
+ document: actorByUuidQuery,
+ variableValues: { uuid: actor.actor.id },
+ contextValue: makeGuestContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+ assert.equal(byUuid.errors, undefined);
+ assert.deepEqual(toPlainJson(byUuid.data), {
+ actorByUuid: {
+ id: encodeGlobalID("Actor", actor.actor.id),
+ handle: "@actorlookupgraphql@localhost",
+ },
+ });
+
+ const byHandle = await execute({
+ schema,
+ document: actorByHandleQuery,
+ variableValues: {
+ handle: actor.account.username,
+ allowLocalHandle: true,
+ },
+ contextValue: makeGuestContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+ assert.equal(byHandle.errors, undefined);
+ assert.deepEqual(toPlainJson(byHandle.data), {
+ actorByHandle: {
+ id: encodeGlobalID("Actor", actor.actor.id),
+ handle: "@actorlookupgraphql@localhost",
+ },
+ });
+ });
+});
+
+test("instanceByHost and searchActorsByHandle expose lookup results", async () => {
+ await withRollback(async (tx) => {
+ const local = await insertAccountWithActor(tx, {
+ username: "actorsearchlocal",
+ name: "Actor Search Local",
+ email: "actorsearchlocal@example.com",
+ });
+ const remote = await insertRemoteActor(tx, {
+ username: "actorsearchremote",
+ name: "Actor Search Remote",
+ host: "remote.example",
+ });
+
+ const instance = await execute({
+ schema,
+ document: instanceByHostQuery,
+ variableValues: { host: "remote.example" },
+ contextValue: makeGuestContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+ assert.equal(instance.errors, undefined);
+ assert.deepEqual(toPlainJson(instance.data), {
+ instanceByHost: {
+ host: "remote.example",
+ software: "hackerspub",
+ },
+ });
+
+ const search = await execute({
+ schema,
+ document: searchActorsByHandleQuery,
+ variableValues: { prefix: "actorsearch", limit: 10 },
+ contextValue: makeUserContext(tx, local.account),
+ onError: "NO_PROPAGATE",
+ });
+ assert.equal(search.errors, undefined);
+ const handles = (toPlainJson(search.data) as {
+ searchActorsByHandle: Array<{ handle: string }>;
+ }).searchActorsByHandle.map((actor) => actor.handle);
+
+ assert.ok(handles.includes("@actorsearchlocal@localhost"));
+ assert.ok(handles.includes(`@${remote.username}@${remote.handleHost}`));
+ });
+});
+
+test("recommendedActors excludes followed actors and filters by locale", async () => {
+ await withRollback(async (tx) => {
+ const viewer = await insertAccountWithActor(tx, {
+ username: "actorrecommendviewer",
+ name: "Actor Recommend Viewer",
+ email: "actorrecommendviewer@example.com",
+ });
+ const localCandidate = await insertAccountWithActor(tx, {
+ username: "actorrecommendlocal",
+ name: "Actor Recommend Local",
+ email: "actorrecommendlocal@example.com",
+ });
+ const followedCandidate = await insertAccountWithActor(tx, {
+ username: "actorrecommendfollowed",
+ name: "Actor Recommend Followed",
+ email: "actorrecommendfollowed@example.com",
+ });
+ const remoteCandidate = await insertRemoteActor(tx, {
+ username: "actorrecommendremote",
+ name: "Actor Recommend Remote",
+ host: "remote.example",
+ });
+ await insertNotePost(tx, {
+ account: localCandidate.account,
+ language: "en",
+ content: "Recommended local post",
+ });
+ await insertNotePost(tx, {
+ account: followedCandidate.account,
+ language: "en",
+ content: "Recommended followed post",
+ });
+ await insertRemotePost(tx, {
+ actorId: remoteCandidate.id,
+ language: "ja",
+ contentHtml: "
Japanese remote post
",
+ });
+
+ const fedCtx = createFedCtx(tx);
+ await follow(fedCtx, viewer.account, followedCandidate.actor);
+
+ const result = await execute({
+ schema,
+ document: recommendedActorsQuery,
+ variableValues: { limit: 10, locale: "en-US" },
+ contextValue: makeUserContext(tx, viewer.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+ const handles = (toPlainJson(result.data) as {
+ recommendedActors: Array<{ handle: string }>;
+ }).recommendedActors.map((actor) => actor.handle);
+
+ assert.ok(handles.includes("@actorrecommendlocal@localhost"));
+ assert.ok(!handles.includes("@actorrecommendfollowed@localhost"));
+ assert.ok(!handles.includes("@actorrecommendremote@remote.example"));
+ });
+});
diff --git a/graphql/actor.test.ts b/graphql/actor.test.ts
new file mode 100644
index 00000000..ddec9d16
--- /dev/null
+++ b/graphql/actor.test.ts
@@ -0,0 +1,301 @@
+import { assertEquals } from "@std/assert/equals";
+import { and, eq } from "drizzle-orm";
+import { encodeGlobalID } from "@pothos/plugin-relay";
+import { execute, parse } from "graphql";
+import { follow } from "@hackerspub/models/following";
+import { blockingTable, followingTable } from "@hackerspub/models/schema";
+import { schema } from "./mod.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ makeUserContext,
+ withRollback,
+} from "../test/postgres.ts";
+
+const followActorMutation = parse(`
+ mutation FollowActor($actorId: ID!) {
+ followActor(input: { actorId: $actorId }) {
+ __typename
+ ... on FollowActorPayload {
+ followee { id }
+ follower { id }
+ }
+ ... on InvalidInputError { inputPath }
+ ... on NotAuthenticatedError { notAuthenticated }
+ }
+ }
+`);
+
+const unfollowActorMutation = parse(`
+ mutation UnfollowActor($actorId: ID!) {
+ unfollowActor(input: { actorId: $actorId }) {
+ __typename
+ ... on UnfollowActorPayload {
+ followee { id }
+ follower { id }
+ }
+ ... on InvalidInputError { inputPath }
+ ... on NotAuthenticatedError { notAuthenticated }
+ }
+ }
+`);
+
+const removeFollowerMutation = parse(`
+ mutation RemoveFollower($actorId: ID!) {
+ removeFollower(input: { actorId: $actorId }) {
+ __typename
+ ... on RemoveFollowerPayload {
+ followee { id }
+ follower { id }
+ }
+ ... on InvalidInputError { inputPath }
+ ... on NotAuthenticatedError { notAuthenticated }
+ }
+ }
+`);
+
+const blockActorMutation = parse(`
+ mutation BlockActor($actorId: ID!) {
+ blockActor(input: { actorId: $actorId }) {
+ __typename
+ ... on BlockActorPayload {
+ blocker { id }
+ blockee { id }
+ }
+ ... on InvalidInputError { inputPath }
+ ... on NotAuthenticatedError { notAuthenticated }
+ }
+ }
+`);
+
+const unblockActorMutation = parse(`
+ mutation UnblockActor($actorId: ID!) {
+ unblockActor(input: { actorId: $actorId }) {
+ __typename
+ ... on UnblockActorPayload {
+ blocker { id }
+ blockee { id }
+ }
+ ... on InvalidInputError { inputPath }
+ ... on NotAuthenticatedError { notAuthenticated }
+ }
+ }
+`);
+
+Deno.test({
+ name: "followActor rejects attempts to follow yourself",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "selffollow",
+ name: "Self Follow",
+ email: "selffollow@example.com",
+ });
+
+ const result = await execute({
+ schema,
+ document: followActorMutation,
+ variableValues: {
+ actorId: encodeGlobalID("Actor", account.actor.id),
+ },
+ contextValue: makeUserContext(tx, account.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(result.errors, undefined);
+ assertEquals(
+ (result.data as {
+ followActor: { __typename: string; inputPath?: string };
+ }).followActor,
+ {
+ __typename: "InvalidInputError",
+ inputPath: "actorId",
+ },
+ );
+ });
+ },
+});
+
+Deno.test({
+ name: "followActor and unfollowActor round-trip through GraphQL",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const follower = await insertAccountWithActor(tx, {
+ username: "graphqlfollower",
+ name: "GraphQL Follower",
+ email: "graphqlfollower@example.com",
+ });
+ const followee = await insertAccountWithActor(tx, {
+ username: "graphqlfollowee",
+ name: "GraphQL Followee",
+ email: "graphqlfollowee@example.com",
+ });
+ const actorId = encodeGlobalID("Actor", followee.actor.id);
+
+ const followResult = await execute({
+ schema,
+ document: followActorMutation,
+ variableValues: { actorId },
+ contextValue: makeUserContext(tx, follower.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(followResult.errors, undefined);
+ assertEquals(
+ (followResult.data as {
+ followActor: { __typename: string; followee?: { id: string } };
+ }).followActor.__typename,
+ "FollowActorPayload",
+ );
+
+ const storedAfterFollow = await tx.query.followingTable.findFirst({
+ where: {
+ followerId: follower.actor.id,
+ followeeId: followee.actor.id,
+ },
+ });
+ assertEquals(storedAfterFollow?.accepted != null, true);
+
+ const unfollowResult = await execute({
+ schema,
+ document: unfollowActorMutation,
+ variableValues: { actorId },
+ contextValue: makeUserContext(tx, follower.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(unfollowResult.errors, undefined);
+ assertEquals(
+ (unfollowResult.data as {
+ unfollowActor: { __typename: string };
+ }).unfollowActor.__typename,
+ "UnfollowActorPayload",
+ );
+
+ const storedAfterUnfollow = await tx.query.followingTable.findFirst({
+ where: {
+ followerId: follower.actor.id,
+ followeeId: followee.actor.id,
+ },
+ });
+ assertEquals(storedAfterUnfollow, undefined);
+ });
+ },
+});
+
+Deno.test({
+ name: "removeFollower removes an existing follower relation",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const followee = await insertAccountWithActor(tx, {
+ username: "graphqlremovefollowee",
+ name: "GraphQL Remove Followee",
+ email: "graphqlremovefollowee@example.com",
+ });
+ const follower = await insertAccountWithActor(tx, {
+ username: "graphqlremovefollower",
+ name: "GraphQL Remove Follower",
+ email: "graphqlremovefollower@example.com",
+ });
+
+ await follow(fedCtx, follower.account, followee.actor);
+
+ const result = await execute({
+ schema,
+ document: removeFollowerMutation,
+ variableValues: {
+ actorId: encodeGlobalID("Actor", follower.actor.id),
+ },
+ contextValue: makeUserContext(tx, followee.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(result.errors, undefined);
+ assertEquals(
+ (result.data as {
+ removeFollower: { __typename: string };
+ }).removeFollower.__typename,
+ "RemoveFollowerPayload",
+ );
+
+ const stored = await tx.select().from(followingTable).where(and(
+ eq(followingTable.followerId, follower.actor.id),
+ eq(followingTable.followeeId, followee.actor.id),
+ ));
+ assertEquals(stored, []);
+ });
+ },
+});
+
+Deno.test({
+ name: "blockActor and unblockActor round-trip through GraphQL",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const blocker = await insertAccountWithActor(tx, {
+ username: "graphqlblocker",
+ name: "GraphQL Blocker",
+ email: "graphqlblocker@example.com",
+ });
+ const blockee = await insertAccountWithActor(tx, {
+ username: "graphqlblockee",
+ name: "GraphQL Blockee",
+ email: "graphqlblockee@example.com",
+ });
+ const actorId = encodeGlobalID("Actor", blockee.actor.id);
+
+ const blockResult = await execute({
+ schema,
+ document: blockActorMutation,
+ variableValues: { actorId },
+ contextValue: makeUserContext(tx, blocker.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(blockResult.errors, undefined);
+ assertEquals(
+ (blockResult.data as { blockActor: { __typename: string } }).blockActor
+ .__typename,
+ "BlockActorPayload",
+ );
+
+ const storedAfterBlock = await tx.select().from(blockingTable).where(and(
+ eq(blockingTable.blockerId, blocker.actor.id),
+ eq(blockingTable.blockeeId, blockee.actor.id),
+ ));
+ assertEquals(storedAfterBlock.length, 1);
+ assertEquals(storedAfterBlock[0].blockeeId, blockee.actor.id);
+
+ const unblockResult = await execute({
+ schema,
+ document: unblockActorMutation,
+ variableValues: { actorId },
+ contextValue: makeUserContext(tx, blocker.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(unblockResult.errors, undefined);
+ assertEquals(
+ (unblockResult.data as { unblockActor: { __typename: string } })
+ .unblockActor.__typename,
+ "UnblockActorPayload",
+ );
+
+ const storedAfterUnblock = await tx.select().from(blockingTable).where(
+ and(
+ eq(blockingTable.blockerId, blocker.actor.id),
+ eq(blockingTable.blockeeId, blockee.actor.id),
+ ),
+ );
+ assertEquals(storedAfterUnblock, []);
+ });
+ },
+});
diff --git a/graphql/apns.test.ts b/graphql/apns.test.ts
new file mode 100644
index 00000000..d4ba2950
--- /dev/null
+++ b/graphql/apns.test.ts
@@ -0,0 +1,107 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { execute, parse } from "graphql";
+import { schema } from "./mod.ts";
+import {
+ insertAccountWithActor,
+ makeUserContext,
+ toPlainJson,
+ withRollback,
+} from "../test/postgres.ts";
+
+const validToken = "0123456789abcdef".repeat(4);
+
+const registerMutation = parse(`
+ mutation RegisterApnsDeviceToken($deviceToken: String!) {
+ registerApnsDeviceToken(input: { deviceToken: $deviceToken }) {
+ __typename
+ ... on RegisterApnsDeviceTokenPayload {
+ deviceToken
+ }
+ ... on InvalidInputError {
+ inputPath
+ }
+ }
+ }
+`);
+
+const unregisterMutation = parse(`
+ mutation UnregisterApnsDeviceToken($deviceToken: String!) {
+ unregisterApnsDeviceToken(input: { deviceToken: $deviceToken }) {
+ __typename
+ ... on UnregisterApnsDeviceTokenPayload {
+ deviceToken
+ unregistered
+ }
+ }
+ }
+`);
+
+test("registerApnsDeviceToken rejects invalid device tokens", async () => {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "graphqlapnsinvalid",
+ name: "GraphQL APNS Invalid",
+ email: "graphqlapnsinvalid@example.com",
+ });
+
+ const result = await execute({
+ schema,
+ document: registerMutation,
+ variableValues: { deviceToken: "invalid-token" },
+ contextValue: makeUserContext(tx, account.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+ assert.deepEqual(toPlainJson(result.data), {
+ registerApnsDeviceToken: {
+ __typename: "InvalidInputError",
+ inputPath: "deviceToken",
+ },
+ });
+ });
+});
+
+test("registerApnsDeviceToken and unregisterApnsDeviceToken round-trip through GraphQL", async () => {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "graphqlapnsowner",
+ name: "GraphQL APNS Owner",
+ email: "graphqlapnsowner@example.com",
+ });
+
+ const registerResult = await execute({
+ schema,
+ document: registerMutation,
+ variableValues: { deviceToken: validToken.toUpperCase() },
+ contextValue: makeUserContext(tx, account.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(registerResult.errors, undefined);
+ assert.deepEqual(toPlainJson(registerResult.data), {
+ registerApnsDeviceToken: {
+ __typename: "RegisterApnsDeviceTokenPayload",
+ deviceToken: validToken,
+ },
+ });
+
+ const unregisterResult = await execute({
+ schema,
+ document: unregisterMutation,
+ variableValues: { deviceToken: validToken },
+ contextValue: makeUserContext(tx, account.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(unregisterResult.errors, undefined);
+ assert.deepEqual(toPlainJson(unregisterResult.data), {
+ unregisterApnsDeviceToken: {
+ __typename: "UnregisterApnsDeviceTokenPayload",
+ deviceToken: validToken,
+ unregistered: true,
+ },
+ });
+ });
+});
diff --git a/graphql/doc.test.ts b/graphql/doc.test.ts
new file mode 100644
index 00000000..6f289d3c
--- /dev/null
+++ b/graphql/doc.test.ts
@@ -0,0 +1,177 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { execute, parse } from "graphql";
+import { schema } from "./mod.ts";
+import {
+ createFedCtx,
+ createTestKv,
+ makeGuestContext,
+ toPlainJson,
+ withRollback,
+} from "../test/postgres.ts";
+
+const codeOfConductQuery = parse(`
+ query CodeOfConduct($locale: Locale!) {
+ codeOfConduct(locale: $locale) {
+ locale
+ title
+ markdown
+ html
+ }
+ }
+`);
+
+const markdownGuideQuery = parse(`
+ query MarkdownGuide($locale: Locale!) {
+ markdownGuide(locale: $locale) {
+ locale
+ title
+ markdown
+ html
+ }
+ }
+`);
+
+const searchGuideQuery = parse(`
+ query SearchGuide($locale: Locale!) {
+ searchGuide(locale: $locale) {
+ locale
+ title
+ markdown
+ html
+ }
+ }
+`);
+
+const privacyPolicyQuery = parse(`
+ query PrivacyPolicy($locale: Locale!) {
+ privacyPolicy(locale: $locale) {
+ locale
+ title
+ markdown
+ html
+ }
+ }
+`);
+
+function makeDocContext(
+ tx: Parameters[0] extends (tx: infer T) => Promise
+ ? T
+ : never,
+) {
+ const { kv } = createTestKv();
+ const fedCtx = createFedCtx(tx, { kv });
+ fedCtx.getDocumentLoader = () => Promise.resolve({}) as never;
+ fedCtx.lookupObject = () => Promise.resolve(null);
+
+ return makeGuestContext(tx, {
+ fedCtx,
+ kv,
+ });
+}
+
+test("codeOfConduct falls back from regioned locales to the base locale", async () => {
+ await withRollback(async (tx) => {
+ const result = await execute({
+ schema,
+ document: codeOfConductQuery,
+ variableValues: { locale: "en-US" },
+ contextValue: makeDocContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+ const document = (toPlainJson(result.data) as {
+ codeOfConduct: {
+ locale: string;
+ title: string;
+ markdown: string;
+ html: string;
+ };
+ }).codeOfConduct;
+
+ assert.equal(document.locale, "en");
+ assert.ok(document.title.length > 0);
+ assert.match(document.markdown, /^#/m);
+ assert.match(document.html, / {
+ await withRollback(async (tx) => {
+ const result = await execute({
+ schema,
+ document: markdownGuideQuery,
+ variableValues: { locale: "ko" },
+ contextValue: makeDocContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+ const document = (toPlainJson(result.data) as {
+ markdownGuide: {
+ locale: string;
+ title: string;
+ markdown: string;
+ html: string;
+ };
+ }).markdownGuide;
+
+ assert.equal(document.locale, "ko");
+ assert.ok(document.markdown.length > 0);
+ assert.ok(document.html.length > 0);
+ });
+});
+
+test("searchGuide returns the requested locale document", async () => {
+ await withRollback(async (tx) => {
+ const result = await execute({
+ schema,
+ document: searchGuideQuery,
+ variableValues: { locale: "ja" },
+ contextValue: makeDocContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+ const document = (toPlainJson(result.data) as {
+ searchGuide: {
+ locale: string;
+ title: string;
+ markdown: string;
+ html: string;
+ };
+ }).searchGuide;
+
+ assert.equal(document.locale, "ja");
+ assert.match(document.markdown, /lang:/);
+ assert.match(document.html, /
{
+ await withRollback(async (tx) => {
+ const result = await execute({
+ schema,
+ document: privacyPolicyQuery,
+ variableValues: { locale: "zh-HK" },
+ contextValue: makeDocContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+ const document = (toPlainJson(result.data) as {
+ privacyPolicy: {
+ locale: string;
+ title: string;
+ markdown: string;
+ html: string;
+ };
+ }).privacyPolicy;
+
+ assert.equal(document.locale, "zh-TW");
+ assert.ok(document.title.length > 0);
+ assert.match(document.markdown, /^#/m);
+ assert.match(document.html, / {
+ const owner = await insertAccountWithActor(tx, {
+ username: "linkowner",
+ name: "Link Owner",
+ email: "linkowner@example.com",
+ });
+ await tx.update(accountTable)
+ .set({ leftInvitations: 5 })
+ .where(eq(accountTable.id, owner.account.id));
+
+ const result = await execute({
+ schema,
+ document: createInvitationLinkMutation,
+ variableValues: {
+ invitationsLeft: 2,
+ message: "Welcome aboard",
+ expires: "3 days",
+ },
+ contextValue: makeUserContext(tx, {
+ ...owner.account,
+ leftInvitations: 5,
+ }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(result.errors, undefined);
+
+ const payload = (result.data as {
+ createInvitationLink: {
+ __typename: string;
+ account?: { username: string };
+ invitationLink?: {
+ uuid: string;
+ invitationsLeft: number;
+ message: string | null;
+ } | null;
+ };
+ }).createInvitationLink;
+ assertEquals(payload.__typename, "InvitationLinkPayload");
+ assertEquals(payload.account?.username, "linkowner");
+ assertEquals(payload.invitationLink?.invitationsLeft, 2);
+ assertEquals(payload.invitationLink?.message, "Welcome aboard");
+ assert(payload.invitationLink?.uuid != null);
+
+ const storedAccount = await tx.query.accountTable.findFirst({
+ where: { id: owner.account.id },
+ });
+ assertEquals(storedAccount?.leftInvitations, 3);
+
+ const linkId = payload.invitationLink
+ .uuid as `${string}-${string}-${string}-${string}-${string}`;
+ const storedLink = await tx.query.invitationLinkTable.findFirst({
+ where: { id: linkId },
+ });
+ assertEquals(storedLink?.invitationsLeft, 2);
+
+ const deleteResult = await execute({
+ schema,
+ document: deleteInvitationLinkMutation,
+ variableValues: { id: linkId },
+ contextValue: makeUserContext(tx, {
+ ...owner.account,
+ leftInvitations: 3,
+ }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(deleteResult.errors, undefined);
+ const deletedPayload = (deleteResult.data as {
+ deleteInvitationLink: {
+ __typename: string;
+ account?: { username: string };
+ invitationLink: null;
+ };
+ }).deleteInvitationLink;
+ assertEquals(deletedPayload.__typename, "InvitationLinkPayload");
+ assertEquals(deletedPayload.account?.username, "linkowner");
+ assertEquals(deletedPayload.invitationLink, null);
+
+ const refundedAccount = await tx.query.accountTable.findFirst({
+ where: { id: owner.account.id },
+ });
+ assertEquals(refundedAccount?.leftInvitations, 5);
+ const deletedLink = await tx.query.invitationLinkTable.findFirst({
+ where: { id: linkId },
+ });
+ assertEquals(deletedLink, undefined);
+ });
+ },
+});
+
+Deno.test({
+ name: "redeemInvitationLink validates verify URL origin",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const owner = await insertAccountWithActor(tx, {
+ username: "linkredeemowner",
+ name: "Link Redeem Owner",
+ email: "linkredeemowner@example.com",
+ });
+ const linkId = crypto
+ .randomUUID() as `${string}-${string}-${string}-${string}-${string}`;
+ await tx.insert(invitationLinkTable).values({
+ id: linkId,
+ inviterId: owner.account.id,
+ invitationsLeft: 1,
+ });
+
+ const result = await execute({
+ schema,
+ document: redeemInvitationLinkMutation,
+ variableValues: {
+ id: linkId,
+ email: "redeem@example.com",
+ locale: "en-US",
+ verifyUrl: "https://evil.example/sign/up/{token}?code={code}",
+ },
+ contextValue: makeGuestContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(result.errors, undefined);
+ const validation = (result.data as {
+ redeemInvitationLink: {
+ __typename: string;
+ verifyUrl: string | null;
+ email: string | null;
+ link: string | null;
+ sendFailed: boolean | null;
+ };
+ }).redeemInvitationLink;
+ assertEquals(
+ validation.__typename,
+ "RedeemInvitationLinkValidationErrors",
+ );
+ assertEquals(validation.verifyUrl, "VERIFY_URL_INVALID_ORIGIN");
+ assertEquals(validation.email, null);
+ assertEquals(validation.link, null);
+ assertEquals(validation.sendFailed, null);
+ });
+ },
+});
+
+Deno.test({
+ name: "redeemInvitationLink decrements the link and stores a signup token",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const { kv, store } = createTestKv();
+ const email = createTestEmailTransport();
+ const owner = await insertAccountWithActor(tx, {
+ username: "redeemsuccessowner",
+ name: "Redeem Success Owner",
+ email: "redeemsuccessowner@example.com",
+ });
+ const linkId = crypto
+ .randomUUID() as `${string}-${string}-${string}-${string}-${string}`;
+ await tx.insert(invitationLinkTable).values({
+ id: linkId,
+ inviterId: owner.account.id,
+ invitationsLeft: 1,
+ message: "Hello",
+ });
+
+ const result = await execute({
+ schema,
+ document: redeemInvitationLinkMutation,
+ variableValues: {
+ id: linkId,
+ email: "redeem@example.com",
+ locale: "en-US",
+ verifyUrl: "http://localhost/sign/up/{token}?code={code}",
+ },
+ contextValue: makeGuestContext(tx, { kv, email: email.transport }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(result.errors, undefined);
+
+ const payload = (result.data as {
+ redeemInvitationLink: {
+ __typename: string;
+ email?: string;
+ invitationLink?: { uuid: string };
+ };
+ }).redeemInvitationLink;
+ assertEquals(payload.__typename, "RedeemInvitationLinkSuccess");
+ assertEquals(payload.email, "redeem@example.com");
+ assertEquals(payload.invitationLink?.uuid, linkId);
+ assertEquals(email.messages.length, 1);
+
+ const storedLink = await tx.query.invitationLinkTable.findFirst({
+ where: { id: linkId },
+ });
+ assertEquals(storedLink?.invitationsLeft, 0);
+
+ const signupEntries = [...store.entries()].filter(([key]) =>
+ key.startsWith("signup/")
+ );
+ assertEquals(signupEntries.length, 1);
+ const token = signupEntries[0][1] as {
+ email: string;
+ inviterId?: string;
+ };
+ assertEquals(token.email, "redeem@example.com");
+ assertEquals(token.inviterId, owner.account.id);
+ });
+ },
+});
diff --git a/graphql/invite.test.ts b/graphql/invite.test.ts
new file mode 100644
index 00000000..70602315
--- /dev/null
+++ b/graphql/invite.test.ts
@@ -0,0 +1,243 @@
+import { assertEquals } from "@std/assert/equals";
+import { accountTable } from "@hackerspub/models/schema";
+import { execute, parse } from "graphql";
+import { eq } from "drizzle-orm";
+import { schema } from "./mod.ts";
+import {
+ createTestEmailTransport,
+ createTestKv,
+ insertAccountWithActor,
+ makeUserContext,
+ withRollback,
+} from "../test/postgres.ts";
+
+const inviteMutation = parse(`
+ mutation Invite(
+ $email: Email!
+ $locale: Locale!
+ $message: Markdown
+ $verifyUrl: URITemplate!
+ ) {
+ invite(
+ email: $email
+ locale: $locale
+ message: $message
+ verifyUrl: $verifyUrl
+ ) {
+ __typename
+ ... on Invitation {
+ email
+ locale
+ message
+ inviter {
+ username
+ }
+ }
+ ... on InviteValidationErrors {
+ inviter
+ email
+ verifyUrl
+ }
+ }
+ }
+`);
+
+Deno.test({
+ name: "invite validates verify URLs that do not interpolate the token",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "invitevalidator",
+ name: "Invite Validator",
+ email: "invitevalidator@example.com",
+ });
+ await tx.update(accountTable)
+ .set({ leftInvitations: 1 })
+ .where(eq(accountTable.id, account.account.id));
+
+ const result = await execute({
+ schema,
+ document: inviteMutation,
+ variableValues: {
+ email: "person@example.com",
+ locale: "en-US",
+ message: null,
+ verifyUrl: "http://localhost/sign/up/static?code={code}",
+ },
+ contextValue: makeUserContext(tx, {
+ ...account.account,
+ leftInvitations: 1,
+ }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(result.errors, undefined);
+ assertEquals(
+ (result.data as {
+ invite: {
+ __typename: string;
+ inviter: string | null;
+ email: string | null;
+ verifyUrl: string | null;
+ };
+ }).invite,
+ {
+ __typename: "InviteValidationErrors",
+ inviter: null,
+ email: null,
+ verifyUrl: "VERIFY_URL_NO_TOKEN",
+ },
+ );
+ });
+ },
+});
+
+Deno.test({
+ name: "invite sends email and stores a signup token on success",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const { kv, store } = createTestKv();
+ const email = createTestEmailTransport();
+ const inviter = await insertAccountWithActor(tx, {
+ username: "inviteowner",
+ name: "Invite Owner",
+ email: "inviteowner@example.com",
+ });
+ await tx.update(accountTable)
+ .set({ leftInvitations: 1 })
+ .where(eq(accountTable.id, inviter.account.id));
+
+ const result = await execute({
+ schema,
+ document: inviteMutation,
+ variableValues: {
+ email: "invitee@example.com",
+ locale: "en-US",
+ message: "Join us",
+ verifyUrl: "http://localhost/sign/up/{token}?code={code}",
+ },
+ contextValue: makeUserContext(tx, {
+ ...inviter.account,
+ leftInvitations: 1,
+ }, {
+ kv,
+ email: email.transport,
+ }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(result.errors, undefined);
+
+ const invitation = (result.data as {
+ invite: {
+ __typename: string;
+ email?: string;
+ locale?: string;
+ message?: string | null;
+ inviter?: { username: string };
+ };
+ }).invite;
+ assertEquals(invitation.__typename, "Invitation");
+ assertEquals(invitation.email, "invitee@example.com");
+ assertEquals(invitation.locale, "en-US");
+ assertEquals(invitation.message, "Join us");
+ assertEquals(invitation.inviter?.username, "inviteowner");
+ assertEquals(email.messages.length, 1);
+
+ const storedAccount = await tx.query.accountTable.findFirst({
+ where: { id: inviter.account.id },
+ });
+ assertEquals(storedAccount?.leftInvitations, 0);
+
+ const signupEntries = [...store.entries()].filter(([key]) =>
+ key.startsWith("signup/")
+ );
+ assertEquals(signupEntries.length, 1);
+ const token = signupEntries[0][1] as {
+ email: string;
+ inviterId?: string;
+ };
+ assertEquals(token.email, "invitee@example.com");
+ assertEquals(token.inviterId, inviter.account.id);
+ });
+ },
+});
+
+Deno.test({
+ name: "invite refunds invitations when email sending fails",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const { kv, store } = createTestKv();
+ const inviter = await insertAccountWithActor(tx, {
+ username: "invitefailureowner",
+ name: "Invite Failure Owner",
+ email: "invitefailureowner@example.com",
+ });
+ await tx.update(accountTable)
+ .set({ leftInvitations: 1 })
+ .where(eq(accountTable.id, inviter.account.id));
+
+ const failingEmail = {
+ send() {
+ return Promise.resolve({
+ successful: false,
+ errorMessages: ["delivery failed"],
+ });
+ },
+ async *sendMany() {
+ yield* [];
+ },
+ };
+
+ const result = await execute({
+ schema,
+ document: inviteMutation,
+ variableValues: {
+ email: "failure@example.com",
+ locale: "en-US",
+ message: null,
+ verifyUrl: "http://localhost/sign/up/{token}?code={code}",
+ },
+ contextValue: makeUserContext(tx, {
+ ...inviter.account,
+ leftInvitations: 1,
+ }, {
+ kv,
+ email: failingEmail as never,
+ }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(result.errors, undefined);
+ assertEquals(
+ (result.data as {
+ invite: {
+ __typename: string;
+ inviter: string | null;
+ email: string | null;
+ verifyUrl: string | null;
+ };
+ }).invite,
+ {
+ __typename: "InviteValidationErrors",
+ inviter: "INVITER_EMAIL_SEND_FAILED",
+ email: null,
+ verifyUrl: null,
+ },
+ );
+
+ const storedAccount = await tx.query.accountTable.findFirst({
+ where: { id: inviter.account.id },
+ });
+ assertEquals(storedAccount?.leftInvitations, 1);
+ const signupEntries = [...store.entries()];
+ assertEquals(signupEntries.length, 0);
+ });
+ },
+});
diff --git a/graphql/invite.ts b/graphql/invite.ts
index 7646340b..dcc52952 100644
--- a/graphql/invite.ts
+++ b/graphql/invite.ts
@@ -1,6 +1,9 @@
import { normalizeEmail } from "@hackerspub/models/account";
import { accountTable } from "@hackerspub/models/schema";
-import { createSignupToken } from "@hackerspub/models/signup";
+import {
+ createSignupToken,
+ deleteSignupToken,
+} from "@hackerspub/models/signup";
import type { Uuid } from "@hackerspub/models/uuid";
import { getLogger } from "@logtape/logtape";
import { and, eq, gt, sql } from "drizzle-orm";
@@ -184,15 +187,17 @@ builder.mutationField("invite", (t) =>
}
if (
errors.inviter != null || errors.email != null ||
- errors.email != null || ctx.account == null || email == null
+ errors.verifyUrl != null
) {
return errors;
}
+ const inviter = ctx.account!;
+ const normalizedEmail = email!;
const updated = await ctx.db.update(accountTable).set({
leftInvitations: sql`${accountTable.leftInvitations} - 1`,
}).where(
and(
- eq(accountTable.id, ctx.account.id),
+ eq(accountTable.id, inviter.id),
gt(accountTable.leftInvitations, 0),
),
).returning();
@@ -201,38 +206,56 @@ builder.mutationField("invite", (t) =>
inviter: "INVITER_NO_INVITATIONS_LEFT",
} satisfies InviteValidationErrors;
}
- const token = await createSignupToken(ctx.kv, email, {
- inviterId: ctx.account.id,
+ const token = await createSignupToken(ctx.kv, normalizedEmail, {
+ inviterId: inviter.id,
expiration: EXPIRATION,
});
- const message = await getEmailMessage({
- locale: args.locale,
- inviter: ctx.account,
- verifyUrlTemplate: args.verifyUrl,
- to: email,
- token,
- message: args.message ?? undefined,
- expiration: EXPIRATION,
- });
- const receipt = await ctx.email.send(message);
- if (!receipt.successful) {
- logger.error(
- "Failed to send invitation email: {errors}",
- { errors: receipt.errorMessages },
- );
- // Credit back the invitation on email send failure
- await ctx.db.update(accountTable).set({
+ const refundInvitation = () =>
+ ctx.db.update(accountTable).set({
leftInvitations: sql`${accountTable.leftInvitations} + 1`,
- }).where(eq(accountTable.id, ctx.account.id));
+ }).where(eq(accountTable.id, inviter.id));
+ const cleanupSignupToken = async () => {
+ try {
+ await deleteSignupToken(ctx.kv, token.token);
+ } catch (error) {
+ logger.error(
+ "Failed to delete signup token after invite failure: {error}",
+ { error },
+ );
+ }
+ };
+ try {
+ const message = await getEmailMessage({
+ locale: args.locale,
+ inviter,
+ verifyUrlTemplate: args.verifyUrl,
+ to: normalizedEmail,
+ token,
+ message: args.message ?? undefined,
+ expiration: EXPIRATION,
+ });
+ const receipt = await ctx.email.send(message);
+ if (!receipt.successful) {
+ logger.error(
+ "Failed to send invitation email: {errors}",
+ { errors: receipt.errorMessages },
+ );
+ await refundInvitation();
+ await cleanupSignupToken();
- // Return validation error to inform the user
- return {
- inviter: "INVITER_EMAIL_SEND_FAILED",
- } satisfies InviteValidationErrors;
+ // Return validation error to inform the user
+ return {
+ inviter: "INVITER_EMAIL_SEND_FAILED",
+ } satisfies InviteValidationErrors;
+ }
+ } catch (error) {
+ await refundInvitation();
+ await cleanupSignupToken();
+ throw error;
}
return {
- inviterId: ctx.account.id,
- email,
+ inviterId: inviter.id,
+ email: normalizedEmail,
locale: args.locale,
message: args.message ?? undefined,
};
diff --git a/graphql/login.more.test.ts b/graphql/login.more.test.ts
new file mode 100644
index 00000000..add0082f
--- /dev/null
+++ b/graphql/login.more.test.ts
@@ -0,0 +1,126 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { execute, parse } from "graphql";
+import { createSigninToken } from "@hackerspub/models/signin";
+import { schema } from "./mod.ts";
+import {
+ createTestEmailTransport,
+ createTestKv,
+ insertAccountWithActor,
+ makeGuestContext,
+ toPlainJson,
+ withRollback,
+} from "../test/postgres.ts";
+
+const loginByUsernameMutation = parse(`
+ mutation LoginByUsername($username: String!, $locale: Locale!, $verifyUrl: URITemplate!) {
+ loginByUsername(username: $username, locale: $locale, verifyUrl: $verifyUrl) {
+ __typename
+ ... on AccountNotFoundError { query }
+ }
+ }
+`);
+
+const loginByEmailMutation = parse(`
+ mutation LoginByEmail($email: String!, $locale: Locale!, $verifyUrl: URITemplate!) {
+ loginByEmail(email: $email, locale: $locale, verifyUrl: $verifyUrl) {
+ __typename
+ ... on AccountNotFoundError { query }
+ }
+ }
+`);
+
+const completeLoginChallengeMutation = parse(`
+ mutation CompleteLoginChallenge($token: UUID!, $code: String!) {
+ completeLoginChallenge(token: $token, code: $code) {
+ id
+ }
+ }
+`);
+
+test("loginByUsername and loginByEmail return AccountNotFoundError for unknown accounts", async () => {
+ await withRollback(async (tx) => {
+ const { kv } = createTestKv();
+ const email = createTestEmailTransport();
+
+ const byUsername = await execute({
+ schema,
+ document: loginByUsernameMutation,
+ variableValues: {
+ username: "missing-user",
+ locale: "en-US",
+ verifyUrl: "http://localhost/sign/in/{token}?code={code}",
+ },
+ contextValue: makeGuestContext(tx, { kv, email: email.transport }),
+ onError: "NO_PROPAGATE",
+ });
+ assert.equal(byUsername.errors, undefined);
+ assert.deepEqual(toPlainJson(byUsername.data), {
+ loginByUsername: {
+ __typename: "AccountNotFoundError",
+ query: "missing-user",
+ },
+ });
+
+ const byEmail = await execute({
+ schema,
+ document: loginByEmailMutation,
+ variableValues: {
+ email: "missing@example.com",
+ locale: "en-US",
+ verifyUrl: "http://localhost/sign/in/{token}?code={code}",
+ },
+ contextValue: makeGuestContext(tx, { kv, email: email.transport }),
+ onError: "NO_PROPAGATE",
+ });
+ assert.equal(byEmail.errors, undefined);
+ assert.deepEqual(toPlainJson(byEmail.data), {
+ loginByEmail: {
+ __typename: "AccountNotFoundError",
+ query: "missing@example.com",
+ },
+ });
+ });
+});
+
+test("completeLoginChallenge returns null for wrong codes and missing tokens", async () => {
+ await withRollback(async (tx) => {
+ const { kv } = createTestKv();
+ const account = await insertAccountWithActor(tx, {
+ username: "loginchallengeowner",
+ name: "Login Challenge Owner",
+ email: "loginchallengeowner@example.com",
+ });
+ const token = await createSigninToken(kv, account.account.id);
+
+ const wrongCode = await execute({
+ schema,
+ document: completeLoginChallengeMutation,
+ variableValues: {
+ token: token.token,
+ code: "WRONGCODE",
+ },
+ contextValue: makeGuestContext(tx, { kv }),
+ onError: "NO_PROPAGATE",
+ });
+ assert.equal(wrongCode.errors, undefined);
+ assert.deepEqual(toPlainJson(wrongCode.data), {
+ completeLoginChallenge: null,
+ });
+
+ const missingToken = await execute({
+ schema,
+ document: completeLoginChallengeMutation,
+ variableValues: {
+ token: "019d9162-ffff-7fff-8fff-ffffffffffff",
+ code: token.code,
+ },
+ contextValue: makeGuestContext(tx, { kv }),
+ onError: "NO_PROPAGATE",
+ });
+ assert.equal(missingToken.errors, undefined);
+ assert.deepEqual(toPlainJson(missingToken.data), {
+ completeLoginChallenge: null,
+ });
+ });
+});
diff --git a/graphql/login.test.ts b/graphql/login.test.ts
new file mode 100644
index 00000000..1ed90014
--- /dev/null
+++ b/graphql/login.test.ts
@@ -0,0 +1,265 @@
+import { assert } from "@std/assert/assert";
+import { assertEquals } from "@std/assert/equals";
+import { createSession, getSession } from "@hackerspub/models/session";
+import { getSigninToken } from "@hackerspub/models/signin";
+import { schema } from "./mod.ts";
+import {
+ createTestEmailTransport,
+ createTestKv,
+ insertAccountWithActor,
+ makeGuestContext,
+ makeUserContext,
+ withRollback,
+} from "../test/postgres.ts";
+import { execute, parse } from "graphql";
+
+const loginByUsernameMutation = parse(`
+ mutation LoginByUsername(
+ $username: String!
+ $locale: Locale!
+ $verifyUrl: URITemplate!
+ ) {
+ loginByUsername(
+ username: $username
+ locale: $locale
+ verifyUrl: $verifyUrl
+ ) {
+ __typename
+ ... on LoginChallenge {
+ token
+ account {
+ username
+ }
+ }
+ ... on AccountNotFoundError {
+ query
+ }
+ }
+ }
+`);
+
+const loginByEmailMutation = parse(`
+ mutation LoginByEmail(
+ $email: String!
+ $locale: Locale!
+ $verifyUrl: URITemplate!
+ ) {
+ loginByEmail(email: $email, locale: $locale, verifyUrl: $verifyUrl) {
+ __typename
+ ... on LoginChallenge {
+ token
+ account {
+ username
+ }
+ }
+ ... on AccountNotFoundError {
+ query
+ }
+ }
+ }
+`);
+
+const completeLoginChallengeMutation = parse(`
+ mutation CompleteLoginChallenge($token: UUID!, $code: String!) {
+ completeLoginChallenge(token: $token, code: $code) {
+ id
+ account {
+ username
+ }
+ }
+ }
+`);
+
+const revokeSessionMutation = parse(`
+ mutation RevokeSession($sessionId: UUID!) {
+ revokeSession(sessionId: $sessionId) {
+ id
+ }
+ }
+`);
+
+Deno.test({
+ name:
+ "loginByUsername creates a challenge and completeLoginChallenge issues a session",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const { kv } = createTestKv();
+ const email = createTestEmailTransport();
+ await insertAccountWithActor(tx, {
+ username: "loginuser",
+ name: "Login User",
+ email: "loginuser@example.com",
+ });
+
+ const challengeResult = await execute({
+ schema,
+ document: loginByUsernameMutation,
+ variableValues: {
+ username: "loginuser",
+ locale: "en-US",
+ verifyUrl: "http://localhost/sign/in/{token}?code={code}",
+ },
+ contextValue: makeGuestContext(tx, { kv, email: email.transport }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(challengeResult.errors, undefined);
+
+ const challenge = (challengeResult.data as {
+ loginByUsername: {
+ __typename: string;
+ token?: string;
+ account?: { username: string };
+ };
+ }).loginByUsername;
+ assertEquals(challenge.__typename, "LoginChallenge");
+ assertEquals(challenge.account?.username, "loginuser");
+ assert(challenge.token != null);
+ assertEquals(email.messages.length, 1);
+
+ const signinToken = await getSigninToken(
+ kv,
+ challenge.token as `${string}-${string}-${string}-${string}-${string}`,
+ );
+ assert(signinToken != null);
+
+ const sessionResult = await execute({
+ schema,
+ document: completeLoginChallengeMutation,
+ variableValues: {
+ token: challenge.token,
+ code: signinToken.code,
+ },
+ contextValue: makeGuestContext(tx, {
+ kv,
+ request: new Request("http://localhost/graphql", {
+ headers: { "user-agent": "login-test" },
+ }),
+ }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(sessionResult.errors, undefined);
+
+ const session = (sessionResult.data as {
+ completeLoginChallenge: {
+ id: string;
+ account: { username: string };
+ } | null;
+ }).completeLoginChallenge;
+ assert(session != null);
+ assertEquals(session.account.username, "loginuser");
+
+ const storedSession = await getSession(
+ kv,
+ session.id as `${string}-${string}-${string}-${string}-${string}`,
+ );
+ assertEquals(storedSession?.userAgent, "login-test");
+ assertEquals(
+ await getSigninToken(
+ kv,
+ challenge
+ .token as `${string}-${string}-${string}-${string}-${string}`,
+ ),
+ undefined,
+ );
+ });
+ },
+});
+
+Deno.test({
+ name: "loginByEmail matches email case-insensitively",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const { kv } = createTestKv();
+ const email = createTestEmailTransport();
+ await insertAccountWithActor(tx, {
+ username: "emailloginuser",
+ name: "Email Login User",
+ email: "EmailLogin@Example.com",
+ });
+
+ const result = await execute({
+ schema,
+ document: loginByEmailMutation,
+ variableValues: {
+ email: "emaillogin@example.com",
+ locale: "en-US",
+ verifyUrl: "http://localhost/sign/in/{token}?code={code}",
+ },
+ contextValue: makeGuestContext(tx, { kv, email: email.transport }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(result.errors, undefined);
+ const challenge = (result.data as {
+ loginByEmail: {
+ __typename: string;
+ account?: { username: string };
+ token?: string;
+ };
+ }).loginByEmail;
+ assertEquals(challenge.__typename, "LoginChallenge");
+ assertEquals(challenge.account?.username, "emailloginuser");
+ assert(challenge.token != null);
+ assertEquals(email.messages.length, 1);
+ });
+ },
+});
+
+Deno.test({
+ name: "revokeSession only revokes sessions for the current account",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const { kv } = createTestKv();
+ const account = await insertAccountWithActor(tx, {
+ username: "revokeowner",
+ name: "Revoke Owner",
+ email: "revokeowner@example.com",
+ });
+ const other = await insertAccountWithActor(tx, {
+ username: "revokeother",
+ name: "Revoke Other",
+ email: "revokeother@example.com",
+ });
+
+ const currentContext = makeUserContext(tx, account.account, { kv });
+ const extraSession = await createSession(kv, {
+ accountId: account.account.id,
+ userAgent: "extra-session",
+ });
+ const otherContext = makeUserContext(tx, other.account, { kv });
+
+ const foreignResult = await execute({
+ schema,
+ document: revokeSessionMutation,
+ variableValues: { sessionId: extraSession.id },
+ contextValue: otherContext,
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(foreignResult.errors, undefined);
+ assertEquals(
+ (foreignResult.data as { revokeSession: null }).revokeSession,
+ null,
+ );
+ assert((await getSession(kv, extraSession.id)) != null);
+
+ await execute({
+ schema,
+ document: revokeSessionMutation,
+ variableValues: { sessionId: extraSession.id },
+ contextValue: currentContext,
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(await getSession(kv, extraSession.id), undefined);
+ });
+ },
+});
diff --git a/graphql/lookup.test.ts b/graphql/lookup.test.ts
new file mode 100644
index 00000000..83a71476
--- /dev/null
+++ b/graphql/lookup.test.ts
@@ -0,0 +1,76 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { lookupPostByUrl, parseHttpUrl } from "./lookup.ts";
+import {
+ insertAccountWithActor,
+ insertNotePost,
+ makeGuestContext,
+ withRollback,
+} from "../test/postgres.ts";
+
+test("parseHttpUrl() accepts only http and https URLs", () => {
+ assert.equal(
+ parseHttpUrl("https://example.com/post")?.href,
+ "https://example.com/post",
+ );
+ assert.equal(
+ parseHttpUrl("http://example.com/post")?.href,
+ "http://example.com/post",
+ );
+ assert.equal(parseHttpUrl("ftp://example.com/post"), null);
+ assert.equal(parseHttpUrl("not a url"), null);
+});
+
+test("lookupPostByUrl() returns a local non-share post by URL", async () => {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "lookuppostauthor",
+ name: "Lookup Post Author",
+ email: "lookuppostauthor@example.com",
+ });
+ const { post } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Lookup me",
+ });
+
+ const found = await lookupPostByUrl(
+ makeGuestContext(tx),
+ new URL(post.url!),
+ );
+
+ assert.ok(found != null);
+ assert.equal(found.id, post.id);
+ });
+});
+
+test("lookupPostByUrl() ignores local share rows when matching URLs", async () => {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "lookupshareauthor",
+ name: "Lookup Share Author",
+ email: "lookupshareauthor@example.com",
+ });
+ const sharer = await insertAccountWithActor(tx, {
+ username: "lookupsharer",
+ name: "Lookup Sharer",
+ email: "lookupsharer@example.com",
+ });
+ const { post: original } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Original post",
+ });
+ const { post: share } = await insertNotePost(tx, {
+ account: sharer.account,
+ actorId: sharer.actor.id,
+ content: "Shared post",
+ sharedPostId: original.id,
+ });
+
+ const ignoredShare = await lookupPostByUrl(
+ makeGuestContext(tx),
+ new URL(share.iri),
+ );
+
+ assert.equal(ignoredShare, null);
+ });
+});
diff --git a/graphql/misc.test.ts b/graphql/misc.test.ts
new file mode 100644
index 00000000..aac0cb93
--- /dev/null
+++ b/graphql/misc.test.ts
@@ -0,0 +1,33 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { execute, parse } from "graphql";
+import { schema } from "./mod.ts";
+import {
+ makeGuestContext,
+ toPlainJson,
+ withRollback,
+} from "../test/postgres.ts";
+
+const availableLocalesQuery = parse(`
+ query AvailableLocales {
+ availableLocales
+ }
+`);
+
+test("availableLocales returns the locale files exposed by the GraphQL layer", async () => {
+ await withRollback(async (tx) => {
+ const result = await execute({
+ schema,
+ document: availableLocalesQuery,
+ contextValue: makeGuestContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+ const locales = (toPlainJson(result.data) as {
+ availableLocales: string[];
+ }).availableLocales;
+
+ assert.deepEqual(locales.sort(), ["en", "ja", "ko", "zh-CN", "zh-TW"]);
+ });
+});
diff --git a/graphql/notification.test.ts b/graphql/notification.test.ts
new file mode 100644
index 00000000..11897355
--- /dev/null
+++ b/graphql/notification.test.ts
@@ -0,0 +1,99 @@
+import { assert } from "@std/assert/assert";
+import { assertEquals } from "@std/assert/equals";
+import { notificationTable } from "@hackerspub/models/schema";
+import { encodeGlobalID } from "@pothos/plugin-relay";
+import { execute, parse } from "graphql";
+import { schema } from "./mod.ts";
+import {
+ insertAccountWithActor,
+ makeUserContext,
+ withRollback,
+} from "../test/postgres.ts";
+
+const notificationActorsQuery = parse(`
+ query NotificationActorsOrderQuery {
+ viewer {
+ notifications(first: 10) {
+ edges {
+ node {
+ ... on FollowNotification {
+ actors(first: 10) {
+ edges {
+ node {
+ id
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+`);
+
+Deno.test({
+ name: "Notification.actors returns actors newest-first",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const recipient = await insertAccountWithActor(tx, {
+ username: "notifyme",
+ name: "Notify Me",
+ email: "notifyme@example.com",
+ });
+ const olderActor = await insertAccountWithActor(tx, {
+ username: "olderactor",
+ name: "Older Actor",
+ email: "olderactor@example.com",
+ });
+ const newerActor = await insertAccountWithActor(tx, {
+ username: "neweractor",
+ name: "Newer Actor",
+ email: "neweractor@example.com",
+ });
+
+ await tx.insert(notificationTable).values({
+ id: crypto.randomUUID(),
+ accountId: recipient.account.id,
+ type: "follow",
+ actorIds: [olderActor.actor.id, newerActor.actor.id],
+ created: new Date("2026-04-15T00:00:00.000Z"),
+ });
+
+ const result = await execute({
+ schema,
+ document: notificationActorsQuery,
+ contextValue: makeUserContext(tx, recipient.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(result.errors, undefined);
+
+ const data = result.data as {
+ viewer: {
+ notifications: {
+ edges: {
+ node: {
+ actors: {
+ edges: { node: { id: string } }[];
+ };
+ };
+ }[];
+ };
+ } | null;
+ };
+
+ const edges = data.viewer?.notifications.edges;
+ assert(edges != null && edges.length > 0);
+ assertEquals(
+ edges[0].node.actors.edges.map((edge) => edge.node.id),
+ [
+ encodeGlobalID("Actor", newerActor.actor.id),
+ encodeGlobalID("Actor", olderActor.actor.id),
+ ],
+ );
+ });
+ },
+});
diff --git a/graphql/passkey.more.test.ts b/graphql/passkey.more.test.ts
new file mode 100644
index 00000000..e180c173
--- /dev/null
+++ b/graphql/passkey.more.test.ts
@@ -0,0 +1,132 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { Buffer } from "node:buffer";
+import { encodeGlobalID } from "@pothos/plugin-relay";
+import { execute, parse } from "graphql";
+import { passkeyTable } from "@hackerspub/models/schema";
+import { schema } from "./mod.ts";
+import {
+ createTestKv,
+ insertAccountWithActor,
+ makeGuestContext,
+ makeUserContext,
+ toPlainJson,
+ withRollback,
+} from "../test/postgres.ts";
+
+const viewerPasskeysQuery = parse(`
+ query ViewerPasskeys {
+ viewer {
+ passkeys(first: 10) {
+ edges {
+ node {
+ id
+ name
+ }
+ }
+ }
+ }
+ }
+`);
+
+const verifyPasskeyRegistrationMutation = parse(`
+ mutation VerifyPasskeyRegistration(
+ $accountId: ID!
+ $name: String!
+ $registrationResponse: JSON!
+ ) {
+ verifyPasskeyRegistration(
+ accountId: $accountId
+ name: $name
+ registrationResponse: $registrationResponse
+ ) {
+ verified
+ passkey { id }
+ }
+ }
+`);
+
+test("Account.passkeys exposes the signed-in account's passkeys", async () => {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "passkeyviewer",
+ name: "Passkey Viewer",
+ email: "passkeyviewer@example.com",
+ });
+ await tx.insert(passkeyTable).values({
+ id: "viewer-passkey",
+ accountId: account.account.id,
+ name: "Laptop",
+ publicKey: Buffer.from([1, 2, 3]),
+ webauthnUserId: "webauthn-user",
+ counter: 0n,
+ deviceType: "singleDevice",
+ backedUp: false,
+ transports: ["internal"],
+ });
+
+ const result = await execute({
+ schema,
+ document: viewerPasskeysQuery,
+ contextValue: makeUserContext(tx, account.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+ assert.deepEqual(toPlainJson(result.data), {
+ viewer: {
+ passkeys: {
+ edges: [{
+ node: {
+ id: encodeGlobalID("Passkey", "viewer-passkey"),
+ name: "Laptop",
+ },
+ }],
+ },
+ },
+ });
+ });
+});
+
+test("verifyPasskeyRegistration requires authentication and a stored challenge", async () => {
+ await withRollback(async (tx) => {
+ const { kv } = createTestKv();
+ const account = await insertAccountWithActor(tx, {
+ username: "verifyregistrationowner",
+ name: "Verify Registration Owner",
+ email: "verifyregistrationowner@example.com",
+ });
+ const variables = {
+ accountId: encodeGlobalID("Account", account.account.id),
+ name: "Phone",
+ registrationResponse: { id: "credential-id" },
+ };
+
+ const guestResult = await execute({
+ schema,
+ document: verifyPasskeyRegistrationMutation,
+ variableValues: variables,
+ contextValue: makeGuestContext(tx, { kv }),
+ onError: "NO_PROPAGATE",
+ });
+ assert.deepEqual(toPlainJson(guestResult.data), {
+ verifyPasskeyRegistration: null,
+ });
+ assert.equal(guestResult.errors?.[0].message, "Not authenticated.");
+
+ const missingChallengeResult = await execute({
+ schema,
+ document: verifyPasskeyRegistrationMutation,
+ variableValues: variables,
+ contextValue: makeUserContext(tx, account.account, { kv }),
+ onError: "NO_PROPAGATE",
+ });
+ assert.deepEqual(toPlainJson(missingChallengeResult.data), {
+ verifyPasskeyRegistration: null,
+ });
+ assert.equal(
+ missingChallengeResult.errors?.[0].message,
+ `Missing registration options for account ${account.account.id}.`,
+ );
+ });
+});
diff --git a/graphql/passkey.test.ts b/graphql/passkey.test.ts
new file mode 100644
index 00000000..306c8f15
--- /dev/null
+++ b/graphql/passkey.test.ts
@@ -0,0 +1,177 @@
+import { assert } from "@std/assert/assert";
+import { assertEquals } from "@std/assert/equals";
+import { encodeGlobalID } from "@pothos/plugin-relay";
+import { execute, parse } from "graphql";
+import { Buffer } from "node:buffer";
+import { passkeyTable } from "@hackerspub/models/schema";
+import { generateUuidV7 } from "@hackerspub/models/uuid";
+import { schema } from "./mod.ts";
+import {
+ createTestKv,
+ insertAccountWithActor,
+ makeGuestContext,
+ makeUserContext,
+ withRollback,
+} from "../test/postgres.ts";
+
+const getRegistrationOptionsMutation = parse(`
+ mutation GetPasskeyRegistrationOptions($accountId: ID!) {
+ getPasskeyRegistrationOptions(accountId: $accountId)
+ }
+`);
+
+const getAuthenticationOptionsMutation = parse(`
+ mutation GetPasskeyAuthenticationOptions($sessionId: UUID!) {
+ getPasskeyAuthenticationOptions(sessionId: $sessionId)
+ }
+`);
+
+const loginByPasskeyMutation = parse(`
+ mutation LoginByPasskey($sessionId: UUID!, $authenticationResponse: JSON!) {
+ loginByPasskey(
+ sessionId: $sessionId
+ authenticationResponse: $authenticationResponse
+ ) {
+ id
+ }
+ }
+`);
+
+const revokePasskeyMutation = parse(`
+ mutation RevokePasskey($passkeyId: ID!) {
+ revokePasskey(passkeyId: $passkeyId)
+ }
+`);
+
+Deno.test({
+ name:
+ "getPasskeyRegistrationOptions stores a challenge for the signed-in account",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const { kv, store } = createTestKv();
+ const account = await insertAccountWithActor(tx, {
+ username: "passkeyowner",
+ name: "Passkey Owner",
+ email: "passkeyowner@example.com",
+ });
+
+ const result = await execute({
+ schema,
+ document: getRegistrationOptionsMutation,
+ variableValues: {
+ accountId: encodeGlobalID("Account", account.account.id),
+ },
+ contextValue: makeUserContext(tx, account.account, { kv }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(result.errors, undefined);
+
+ const options = (result.data as {
+ getPasskeyRegistrationOptions: {
+ challenge: string;
+ user: { name: string };
+ };
+ }).getPasskeyRegistrationOptions;
+ assert(options.challenge.length > 0);
+ assertEquals(options.user.name, "passkeyowner");
+ assert(store.has(`passkey/registration/${account.account.id}`));
+ });
+ },
+});
+
+Deno.test({
+ name:
+ "getPasskeyAuthenticationOptions and loginByPasskey return null for unknown credentials",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const { kv, store } = createTestKv();
+ const sessionId = generateUuidV7();
+
+ const optionsResult = await execute({
+ schema,
+ document: getAuthenticationOptionsMutation,
+ variableValues: { sessionId },
+ contextValue: makeGuestContext(tx, { kv }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(optionsResult.errors, undefined);
+ const options = (optionsResult.data as {
+ getPasskeyAuthenticationOptions: { challenge: string };
+ }).getPasskeyAuthenticationOptions;
+ assert(options.challenge.length > 0);
+ assert(store.has(`passkey/authentication/${sessionId}`));
+
+ const loginResult = await execute({
+ schema,
+ document: loginByPasskeyMutation,
+ variableValues: {
+ sessionId,
+ authenticationResponse: { id: "missing-passkey" },
+ },
+ contextValue: makeGuestContext(tx, { kv }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(loginResult.errors, undefined);
+ assertEquals(
+ (loginResult.data as { loginByPasskey: null }).loginByPasskey,
+ null,
+ );
+ });
+ },
+});
+
+Deno.test({
+ name: "revokePasskey deletes an existing passkey and returns its global ID",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const { kv } = createTestKv();
+ const account = await insertAccountWithActor(tx, {
+ username: "revokepasskeyowner",
+ name: "Revoke Passkey Owner",
+ email: "revokepasskeyowner@example.com",
+ });
+
+ await tx.insert(passkeyTable).values({
+ id: "credential-id",
+ accountId: account.account.id,
+ name: "Laptop",
+ publicKey: Buffer.from([1, 2, 3]),
+ webauthnUserId: "webauthn-user",
+ counter: 0n,
+ deviceType: "singleDevice",
+ backedUp: false,
+ transports: ["internal"],
+ });
+
+ const result = await execute({
+ schema,
+ document: revokePasskeyMutation,
+ variableValues: {
+ passkeyId: encodeGlobalID("Passkey", "credential-id"),
+ },
+ contextValue: makeUserContext(tx, account.account, { kv }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(result.errors, undefined);
+ assertEquals(
+ (result.data as { revokePasskey: string | null }).revokePasskey,
+ encodeGlobalID("Passkey", "credential-id"),
+ );
+
+ const stored = await tx.query.passkeyTable.findFirst({
+ where: { id: "credential-id" },
+ });
+ assertEquals(stored, undefined);
+ });
+ },
+});
diff --git a/graphql/poll.test.ts b/graphql/poll.test.ts
new file mode 100644
index 00000000..6c5bc9e2
--- /dev/null
+++ b/graphql/poll.test.ts
@@ -0,0 +1,180 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { encodeGlobalID } from "@pothos/plugin-relay";
+import { execute, parse } from "graphql";
+import {
+ type NewPost,
+ pollOptionTable,
+ pollTable,
+ pollVoteTable,
+ postTable,
+} from "@hackerspub/models/schema";
+import { generateUuidV7 } from "@hackerspub/models/uuid";
+import { schema } from "./mod.ts";
+import {
+ insertAccountWithActor,
+ makeGuestContext,
+ toPlainJson,
+ withRollback,
+} from "../test/postgres.ts";
+
+const questionPollQuery = parse(`
+ query QuestionPoll($id: ID!) {
+ node(id: $id) {
+ ... on Question {
+ poll {
+ multiple
+ options {
+ title
+ votes(first: 10) {
+ totalCount
+ edges {
+ node {
+ actor { id }
+ }
+ }
+ }
+ }
+ votes(first: 10) {
+ totalCount
+ edges {
+ node {
+ actor { id }
+ option { title }
+ }
+ }
+ }
+ voters(first: 10) {
+ totalCount
+ edges {
+ node { id }
+ }
+ }
+ }
+ }
+ }
+ }
+`);
+
+test("Question.poll exposes ordered options and vote connections", async () => {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "pollgraphqlauthor",
+ name: "Poll GraphQL Author",
+ email: "pollgraphqlauthor@example.com",
+ });
+ const firstVoter = await insertAccountWithActor(tx, {
+ username: "pollgraphqlfirst",
+ name: "Poll GraphQL First",
+ email: "pollgraphqlfirst@example.com",
+ });
+ const secondVoter = await insertAccountWithActor(tx, {
+ username: "pollgraphqlsecond",
+ name: "Poll GraphQL Second",
+ email: "pollgraphqlsecond@example.com",
+ });
+ const questionId = generateUuidV7();
+ const published = new Date("2026-04-15T00:00:00.000Z");
+
+ await tx.insert(postTable).values(
+ {
+ id: questionId,
+ iri: `http://localhost/objects/${questionId}`,
+ type: "Question",
+ visibility: "public",
+ actorId: author.actor.id,
+ name: "Favorite language?",
+ contentHtml: "
Favorite language?
",
+ language: "en",
+ tags: {},
+ emojis: {},
+ url: `http://localhost/@${author.account.username}/polls/${questionId}`,
+ published,
+ updated: published,
+ } satisfies NewPost,
+ );
+ await tx.insert(pollTable).values({
+ postId: questionId,
+ multiple: true,
+ votersCount: 2,
+ ends: new Date("2026-04-16T00:00:00.000Z"),
+ });
+ await tx.insert(pollOptionTable).values([
+ { postId: questionId, index: 1, title: "Rust", votesCount: 1 },
+ { postId: questionId, index: 0, title: "TypeScript", votesCount: 1 },
+ ]);
+ await tx.insert(pollVoteTable).values([
+ {
+ postId: questionId,
+ optionIndex: 0,
+ actorId: firstVoter.actor.id,
+ created: new Date("2026-04-15T00:00:01.000Z"),
+ },
+ {
+ postId: questionId,
+ optionIndex: 1,
+ actorId: secondVoter.actor.id,
+ created: new Date("2026-04-15T00:00:02.000Z"),
+ },
+ ]);
+
+ const result = await execute({
+ schema,
+ document: questionPollQuery,
+ variableValues: { id: encodeGlobalID("Question", questionId) },
+ contextValue: makeGuestContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+
+ const poll = (toPlainJson(result.data) as {
+ node: {
+ poll: {
+ multiple: boolean;
+ options: Array<{
+ title: string;
+ votes: {
+ totalCount: number;
+ edges: Array<{ node: { actor: { id: string } } }>;
+ };
+ }>;
+ votes: {
+ totalCount: number;
+ edges: Array<{
+ node: { actor: { id: string }; option: { title: string } };
+ }>;
+ };
+ voters: {
+ totalCount: number;
+ edges: Array<{ node: { id: string } }>;
+ };
+ };
+ } | null;
+ }).node?.poll;
+
+ assert.ok(poll != null);
+ assert.equal(poll.multiple, true);
+ assert.deepEqual(
+ poll.options.map((option) => option.title),
+ ["TypeScript", "Rust"],
+ );
+ assert.deepEqual(
+ poll.options.map((option) => option.votes.totalCount),
+ [1, 1],
+ );
+ assert.equal(poll.votes.totalCount, 2);
+ assert.deepEqual(
+ poll.votes.edges.map((edge) => edge.node.option.title).sort(),
+ ["Rust", "TypeScript"],
+ );
+ assert.equal(poll.voters.totalCount, 2);
+ assert.deepEqual(
+ poll.voters.edges.map((edge) => edge.node.id).sort(),
+ [
+ encodeGlobalID("Actor", firstVoter.actor.id),
+ encodeGlobalID("Actor", secondVoter.actor.id),
+ ].sort(),
+ );
+ });
+});
diff --git a/graphql/post.more.test.ts b/graphql/post.more.test.ts
new file mode 100644
index 00000000..7eb52997
--- /dev/null
+++ b/graphql/post.more.test.ts
@@ -0,0 +1,461 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { encodeGlobalID } from "@pothos/plugin-relay";
+import { execute, parse } from "graphql";
+import type { UserContext } from "./builder.ts";
+import {
+ articleContentTable,
+ articleDraftTable,
+ articleSourceTable,
+ type NewPost,
+ postTable,
+} from "@hackerspub/models/schema";
+import { generateUuidV7 } from "@hackerspub/models/uuid";
+import { schema } from "./mod.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ insertNotePost,
+ makeUserContext,
+ toPlainJson,
+ withRollback,
+} from "../test/postgres.ts";
+
+const saveArticleDraftMutation = parse(`
+ mutation SaveArticleDraft($input: SaveArticleDraftInput!) {
+ saveArticleDraft(input: $input) {
+ __typename
+ ... on SaveArticleDraftPayload {
+ draft {
+ id
+ uuid
+ title
+ tags
+ }
+ }
+ }
+ }
+`);
+
+const articleDraftQuery = parse(`
+ query ArticleDraft($uuid: UUID!) {
+ articleDraft(uuid: $uuid) {
+ id
+ uuid
+ title
+ tags
+ }
+ }
+`);
+
+const deleteArticleDraftMutation = parse(`
+ mutation DeleteArticleDraft($id: ID!) {
+ deleteArticleDraft(input: { id: $id }) {
+ __typename
+ ... on DeleteArticleDraftPayload {
+ deletedDraftId
+ }
+ }
+ }
+`);
+
+const publishArticleDraftMutation = parse(`
+ mutation PublishArticleDraft($input: PublishArticleDraftInput!) {
+ publishArticleDraft(input: $input) {
+ __typename
+ ... on PublishArticleDraftPayload {
+ article {
+ id
+ slug
+ }
+ deletedDraftId
+ }
+ }
+ }
+`);
+
+const articleByYearAndSlugQuery = parse(`
+ query ArticleByYearAndSlug($handle: String!, $idOrYear: String!, $slug: String!) {
+ articleByYearAndSlug(handle: $handle, idOrYear: $idOrYear, slug: $slug) {
+ id
+ slug
+ }
+ }
+`);
+
+const createNoteMutation = parse(`
+ mutation CreateNote($input: CreateNoteInput!) {
+ createNote(input: $input) {
+ __typename
+ ... on CreateNotePayload {
+ note {
+ id
+ excerpt
+ }
+ }
+ }
+ }
+`);
+
+const deletePostMutation = parse(`
+ mutation DeletePost($id: ID!) {
+ deletePost(input: { id: $id }) {
+ __typename
+ ... on DeletePostPayload {
+ deletedPostId
+ }
+ ... on SharedPostDeletionNotAllowedError {
+ inputPath
+ }
+ }
+ }
+`);
+
+const postByUrlQuery = parse(`
+ query PostByUrl($url: String!) {
+ postByUrl(url: $url) {
+ id
+ }
+ }
+`);
+
+function makeTransactionalUserContext(
+ tx: Parameters[0] extends (tx: infer T) => Promise
+ ? T
+ : never,
+ account: Parameters[1],
+): UserContext {
+ const baseFedCtx = createFedCtx(tx);
+ const fedCtx = {
+ ...baseFedCtx,
+ request: new Request("http://localhost/graphql"),
+ federation: {
+ createContext(request: unknown, data: unknown) {
+ return {
+ ...baseFedCtx,
+ request,
+ data,
+ };
+ },
+ },
+ } as UserContext["fedCtx"];
+ return makeUserContext(tx, account, { fedCtx });
+}
+
+test("saveArticleDraft, articleDraft, and deleteArticleDraft round-trip a draft", async () => {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "draftgraphql",
+ name: "Draft GraphQL",
+ email: "draftgraphql@example.com",
+ });
+
+ const saveResult = await execute({
+ schema,
+ document: saveArticleDraftMutation,
+ variableValues: {
+ input: {
+ title: "Draft title",
+ content: "Draft body",
+ tags: ["relay", "relay", "solid"],
+ },
+ },
+ contextValue: makeTransactionalUserContext(tx, account.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(saveResult.errors, undefined);
+ const savedDraft = (toPlainJson(saveResult.data) as {
+ saveArticleDraft: {
+ __typename: string;
+ draft: { id: string; uuid: string; title: string; tags: string[] };
+ };
+ }).saveArticleDraft.draft;
+
+ assert.equal(savedDraft.title, "Draft title");
+ assert.deepEqual(savedDraft.tags, ["relay", "solid"]);
+
+ const draftQueryResult = await execute({
+ schema,
+ document: articleDraftQuery,
+ variableValues: { uuid: savedDraft.uuid },
+ contextValue: makeUserContext(tx, account.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(draftQueryResult.errors, undefined);
+ assert.deepEqual(toPlainJson(draftQueryResult.data), {
+ articleDraft: {
+ id: encodeGlobalID("ArticleDraft", savedDraft.uuid),
+ uuid: savedDraft.uuid,
+ title: "Draft title",
+ tags: ["relay", "solid"],
+ },
+ });
+
+ const deleteResult = await execute({
+ schema,
+ document: deleteArticleDraftMutation,
+ variableValues: {
+ id: encodeGlobalID("ArticleDraft", savedDraft.uuid),
+ },
+ contextValue: makeUserContext(tx, account.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(deleteResult.errors, undefined);
+ assert.deepEqual(toPlainJson(deleteResult.data), {
+ deleteArticleDraft: {
+ __typename: "DeleteArticleDraftPayload",
+ deletedDraftId: encodeGlobalID("ArticleDraft", savedDraft.uuid),
+ },
+ });
+
+ const storedDraft = await tx.query.articleDraftTable.findFirst({
+ where: {
+ id: savedDraft
+ .uuid as `${string}-${string}-${string}-${string}-${string}`,
+ },
+ });
+ assert.equal(storedDraft, undefined);
+ });
+});
+
+test("publishArticleDraft publishes an article and removes the draft", async () => {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "publishdraftgraphql",
+ name: "Publish Draft GraphQL",
+ email: "publishdraftgraphql@example.com",
+ });
+ const draftId = generateUuidV7();
+ const timestamp = new Date("2026-04-15T00:00:00.000Z");
+
+ await tx.insert(articleDraftTable).values({
+ id: draftId,
+ accountId: account.account.id,
+ title: "Published article",
+ content: "Published **body**",
+ tags: ["federation"],
+ created: timestamp,
+ updated: timestamp,
+ });
+
+ const publishResult = await execute({
+ schema,
+ document: publishArticleDraftMutation,
+ variableValues: {
+ input: {
+ id: encodeGlobalID("ArticleDraft", draftId),
+ slug: "published-article",
+ language: "en",
+ allowLlmTranslation: false,
+ },
+ },
+ contextValue: makeTransactionalUserContext(tx, account.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(publishResult.errors, undefined);
+ const payload = (toPlainJson(publishResult.data) as {
+ publishArticleDraft: {
+ __typename: string;
+ article: { id: string; slug: string };
+ deletedDraftId: string;
+ };
+ }).publishArticleDraft;
+
+ assert.equal(payload.article.slug, "published-article");
+ assert.equal(
+ payload.deletedDraftId,
+ encodeGlobalID("ArticleDraft", draftId),
+ );
+
+ const articleSource = await tx.query.articleSourceTable.findFirst({
+ where: {
+ accountId: account.account.id,
+ slug: "published-article",
+ },
+ with: { contents: true },
+ });
+ assert.ok(articleSource != null);
+ assert.equal(articleSource.contents.length, 1);
+ assert.equal(articleSource.contents[0].title, "Published article");
+
+ const remainingDraft = await tx.query.articleDraftTable.findFirst({
+ where: {
+ id: draftId as `${string}-${string}-${string}-${string}-${string}`,
+ },
+ });
+ assert.equal(remainingDraft, undefined);
+ });
+});
+
+test("articleByYearAndSlug returns a local article by route components", async () => {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "articlelookupgraphql",
+ name: "Article Lookup GraphQL",
+ email: "articlelookupgraphql@example.com",
+ });
+ const sourceId = generateUuidV7();
+ const postId = generateUuidV7();
+ const published = new Date("2026-04-15T00:00:00.000Z");
+
+ await tx.insert(articleSourceTable).values({
+ id: sourceId,
+ accountId: author.account.id,
+ publishedYear: 2026,
+ slug: "route-article",
+ tags: [],
+ allowLlmTranslation: false,
+ published,
+ updated: published,
+ });
+ await tx.insert(articleContentTable).values({
+ sourceId,
+ language: "en",
+ title: "Route Article",
+ content: "Route article body",
+ published,
+ updated: published,
+ });
+ await tx.insert(postTable).values(
+ {
+ id: postId,
+ iri: `http://localhost/objects/${postId}`,
+ type: "Article",
+ visibility: "public",
+ actorId: author.actor.id,
+ articleSourceId: sourceId,
+ name: "Route Article",
+ contentHtml: "Route article body
",
+ language: "en",
+ tags: {},
+ emojis: {},
+ url: `http://localhost/@${author.account.username}/2026/route-article`,
+ published,
+ updated: published,
+ } satisfies NewPost,
+ );
+
+ const result = await execute({
+ schema,
+ document: articleByYearAndSlugQuery,
+ variableValues: {
+ handle: author.account.username,
+ idOrYear: "2026",
+ slug: "route-article",
+ },
+ contextValue: makeUserContext(tx, author.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+ assert.deepEqual(toPlainJson(result.data), {
+ articleByYearAndSlug: {
+ id: encodeGlobalID("Article", postId),
+ slug: "route-article",
+ },
+ });
+ });
+});
+
+test("createNote creates a note for the signed-in account", async () => {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "createnotegraphql",
+ name: "Create Note GraphQL",
+ email: "createnotegraphql@example.com",
+ });
+
+ const result = await execute({
+ schema,
+ document: createNoteMutation,
+ variableValues: {
+ input: {
+ visibility: "PUBLIC",
+ content: "Hello from GraphQL createNote",
+ language: "en",
+ },
+ },
+ contextValue: makeTransactionalUserContext(tx, account.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+ const note = (toPlainJson(result.data) as {
+ createNote: {
+ __typename: string;
+ note: { id: string; excerpt: string };
+ };
+ }).createNote.note;
+
+ assert.equal(note.excerpt, "Hello from GraphQL createNote");
+
+ const createdSources = await tx.query.noteSourceTable.findMany({
+ where: {
+ accountId: account.account.id,
+ content: "Hello from GraphQL createNote",
+ },
+ });
+ assert.equal(createdSources.length, 1);
+ });
+});
+
+test("deletePost rejects deleting shared posts and postByUrl resolves owned posts", async () => {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "deletepostauthor",
+ name: "Delete Post Author",
+ email: "deletepostauthor@example.com",
+ });
+ const sharer = await insertAccountWithActor(tx, {
+ username: "deletepostsharer",
+ name: "Delete Post Sharer",
+ email: "deletepostsharer@example.com",
+ });
+ const { post: original } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Delete target",
+ });
+ const { post: share } = await insertNotePost(tx, {
+ account: sharer.account,
+ content: "Shared delete target",
+ sharedPostId: original.id,
+ });
+
+ const deleteResult = await execute({
+ schema,
+ document: deletePostMutation,
+ variableValues: {
+ id: encodeGlobalID("Note", share.id),
+ },
+ contextValue: makeUserContext(tx, sharer.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(deleteResult.errors, undefined);
+ assert.deepEqual(toPlainJson(deleteResult.data), {
+ deletePost: {
+ __typename: "SharedPostDeletionNotAllowedError",
+ inputPath: "id",
+ },
+ });
+
+ const lookupResult = await execute({
+ schema,
+ document: postByUrlQuery,
+ variableValues: { url: original.url },
+ contextValue: makeUserContext(tx, sharer.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(lookupResult.errors, undefined);
+ assert.deepEqual(toPlainJson(lookupResult.data), {
+ postByUrl: {
+ id: encodeGlobalID("Note", original.id),
+ },
+ });
+ });
+});
diff --git a/graphql/post.test.ts b/graphql/post.test.ts
new file mode 100644
index 00000000..323ba946
--- /dev/null
+++ b/graphql/post.test.ts
@@ -0,0 +1,254 @@
+import { assert } from "@std/assert/assert";
+import { assertEquals } from "@std/assert/equals";
+import { encodeGlobalID } from "@pothos/plugin-relay";
+import { execute, parse } from "graphql";
+import { schema } from "./mod.ts";
+import {
+ insertAccountWithActor,
+ insertNotePost,
+ makeUserContext,
+ withRollback,
+} from "../test/postgres.ts";
+
+const addReactionMutation = parse(`
+ mutation AddReactionToPost($postId: ID!, $emoji: String!) {
+ addReactionToPost(input: { postId: $postId, emoji: $emoji }) {
+ __typename
+ ... on AddReactionToPostPayload {
+ reaction {
+ id
+ }
+ }
+ ... on InvalidInputError {
+ inputPath
+ }
+ ... on NotAuthenticatedError {
+ notAuthenticated
+ }
+ }
+ }
+`);
+
+const shareMutation = parse(`
+ mutation SharePost($postId: ID!) {
+ sharePost(input: { postId: $postId }) {
+ __typename
+ ... on SharePostPayload {
+ originalPost {
+ id
+ }
+ share {
+ id
+ }
+ }
+ ... on InvalidInputError {
+ inputPath
+ }
+ ... on NotAuthenticatedError {
+ notAuthenticated
+ }
+ }
+ }
+`);
+
+const unshareMutation = parse(`
+ mutation UnsharePost($postId: ID!) {
+ unsharePost(input: { postId: $postId }) {
+ __typename
+ ... on UnsharePostPayload {
+ originalPost {
+ id
+ }
+ }
+ ... on InvalidInputError {
+ inputPath
+ }
+ ... on NotAuthenticatedError {
+ notAuthenticated
+ }
+ }
+ }
+`);
+
+Deno.test({
+ name: "addReactionToPost rejects posts not visible to the viewer",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "hiddenauthor",
+ name: "Hidden Author",
+ email: "hiddenauthor@example.com",
+ });
+ const viewer = await insertAccountWithActor(tx, {
+ username: "hiddenviewer",
+ name: "Hidden Viewer",
+ email: "hiddenviewer@example.com",
+ });
+ const { post } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Followers-only note",
+ visibility: "followers",
+ });
+
+ const result = await execute({
+ schema,
+ document: addReactionMutation,
+ variableValues: {
+ postId: encodeGlobalID("Note", post.id),
+ emoji: "❤️",
+ },
+ contextValue: makeUserContext(tx, viewer.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(result.errors, undefined);
+ assertEquals(
+ (result.data as {
+ addReactionToPost: { __typename: string; inputPath?: string };
+ }).addReactionToPost,
+ {
+ __typename: "InvalidInputError",
+ inputPath: "postId",
+ },
+ );
+ });
+ },
+});
+
+Deno.test({
+ name: "addReactionToPost returns the created reaction for visible posts",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "reactionauthor",
+ name: "Reaction Author",
+ email: "reactionauthor@example.com",
+ });
+ const viewer = await insertAccountWithActor(tx, {
+ username: "reactionviewer",
+ name: "Reaction Viewer",
+ email: "reactionviewer@example.com",
+ });
+ const { post } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Public note",
+ });
+
+ const result = await execute({
+ schema,
+ document: addReactionMutation,
+ variableValues: {
+ postId: encodeGlobalID("Note", post.id),
+ emoji: "🎉",
+ },
+ contextValue: makeUserContext(tx, viewer.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(result.errors, undefined);
+
+ const payload = (result.data as {
+ addReactionToPost: {
+ __typename: string;
+ reaction?: { id: string } | null;
+ };
+ }).addReactionToPost;
+ assertEquals(payload.__typename, "AddReactionToPostPayload");
+ assert(payload.reaction?.id != null);
+
+ const reactions = await tx.query.reactionTable.findMany({
+ where: {
+ postId: post.id,
+ actorId: viewer.actor.id,
+ emoji: "🎉",
+ },
+ });
+ assertEquals(reactions.length, 1);
+ });
+ },
+});
+
+Deno.test({
+ name: "sharePost and unsharePost round-trip through GraphQL",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "graphqlshareauthor",
+ name: "GraphQL Share Author",
+ email: "graphqlshareauthor@example.com",
+ });
+ const sharer = await insertAccountWithActor(tx, {
+ username: "graphqlsharer",
+ name: "GraphQL Sharer",
+ email: "graphqlsharer@example.com",
+ });
+ const { post } = await insertNotePost(tx, {
+ account: author.account,
+ content: "GraphQL share target",
+ });
+ const postId = encodeGlobalID("Note", post.id);
+
+ const shareResult = await execute({
+ schema,
+ document: shareMutation,
+ variableValues: { postId },
+ contextValue: makeUserContext(tx, sharer.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(shareResult.errors, undefined);
+
+ const sharePayload = (shareResult.data as {
+ sharePost: {
+ __typename: string;
+ originalPost?: { id: string };
+ share?: { id: string };
+ };
+ }).sharePost;
+ assertEquals(sharePayload.__typename, "SharePostPayload");
+ assertEquals(sharePayload.originalPost?.id, postId);
+ assert(sharePayload.share?.id != null);
+
+ const sharesAfterShare = await tx.query.postTable.findMany({
+ where: {
+ actorId: sharer.actor.id,
+ sharedPostId: post.id,
+ },
+ });
+ assertEquals(sharesAfterShare.length, 1);
+
+ const unshareResult = await execute({
+ schema,
+ document: unshareMutation,
+ variableValues: { postId },
+ contextValue: makeUserContext(tx, sharer.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(unshareResult.errors, undefined);
+
+ const unsharePayload = (unshareResult.data as {
+ unsharePost: {
+ __typename: string;
+ originalPost?: { id: string };
+ };
+ }).unsharePost;
+ assertEquals(unsharePayload.__typename, "UnsharePostPayload");
+ assertEquals(unsharePayload.originalPost?.id, postId);
+
+ const sharesAfterUnshare = await tx.query.postTable.findMany({
+ where: {
+ actorId: sharer.actor.id,
+ sharedPostId: post.id,
+ },
+ });
+ assertEquals(sharesAfterUnshare, []);
+ });
+ },
+});
diff --git a/graphql/reactable.test.ts b/graphql/reactable.test.ts
index f98e9492..fa4e1fbf 100644
--- a/graphql/reactable.test.ts
+++ b/graphql/reactable.test.ts
@@ -1,23 +1,24 @@
import { assert } from "@std/assert/assert";
import { assertEquals } from "@std/assert/equals";
-import {
- accountEmailTable,
- accountTable,
- actorTable,
- instanceTable,
- noteSourceTable,
- postTable,
- reactionTable,
-} from "@hackerspub/models/schema";
import type { Transaction } from "@hackerspub/models/db";
+import { reactionTable } from "@hackerspub/models/schema";
import { generateUuidV7 } from "@hackerspub/models/uuid";
import { encodeGlobalID } from "@pothos/plugin-relay";
import { execute, parse } from "graphql";
-import type { UserContext } from "./builder.ts";
-import { db } from "./db.ts";
import { schema } from "./mod.ts";
-
-type AuthenticatedAccount = NonNullable;
+import {
+ insertAccountWithActor,
+ insertNotePost,
+ makeUserContext,
+ seedLocalInstance,
+ withRollback,
+} from "../test/postgres.ts";
+
+interface ReactedNoteSeedResult {
+ noteId: string;
+ viewerAccount: Awaited>["account"];
+ reactorIds: string[];
+}
const reactorsQuery = parse(`
query ReactorsQuery($id: ID!) {
@@ -56,7 +57,7 @@ Deno.test({
variableValues: {
id: encodeGlobalID("Note", noteId),
},
- contextValue: makeContext(tx, viewerAccount),
+ contextValue: makeUserContext(tx, viewerAccount),
onError: "NO_PROPAGATE",
});
@@ -90,89 +91,50 @@ Deno.test({
},
});
-async function withRollback(run: (tx: Transaction) => Promise) {
- let rolledBack = false;
-
- try {
- await db.transaction(async (tx) => {
- await run(tx);
- rolledBack = true;
- tx.rollback();
- });
- } catch (error) {
- if (!rolledBack) throw error;
- }
-}
-
-async function seedReactedNote(tx: Transaction) {
+async function seedReactedNote(
+ tx: Transaction,
+): Promise {
const timestamp = new Date("2026-04-15T00:00:00.000Z");
+ const suffix = crypto.randomUUID().replaceAll("-", "").slice(0, 8);
- await tx.insert(instanceTable).values({
- host: "localhost",
- software: "hackerspub",
- softwareVersion: "test",
- }).onConflictDoNothing();
+ await seedLocalInstance(tx);
const author = await insertAccountWithActor(tx, {
- username: "author",
+ username: `author${suffix}`,
name: "Author",
- email: "author@example.com",
- iri: "http://localhost/@author",
- inboxUrl: "http://localhost/@author/inbox",
+ email: `author-${suffix}@example.com`,
});
const viewer = await insertAccountWithActor(tx, {
- username: "viewer",
+ username: `viewer${suffix}`,
name: "Viewer",
- email: "viewer@example.com",
- iri: "http://localhost/@viewer",
- inboxUrl: "http://localhost/@viewer/inbox",
+ email: `viewer-${suffix}@example.com`,
});
const other = await insertAccountWithActor(tx, {
- username: "other",
+ username: `other${suffix}`,
name: "Other",
- email: "other@example.com",
- iri: "http://localhost/@other",
- inboxUrl: "http://localhost/@other/inbox",
+ email: `other-${suffix}@example.com`,
});
- const noteSourceId = generateUuidV7();
- await tx.insert(noteSourceTable).values({
- id: noteSourceId,
- accountId: author.account.id,
- visibility: "public",
+ const { post } = await insertNotePost(tx, {
+ account: author.account,
content: "Hello world",
- language: "en",
- published: timestamp,
- updated: timestamp,
- });
-
- const noteId = generateUuidV7();
- await tx.insert(postTable).values({
- id: noteId,
- iri: `http://localhost/objects/${noteId}`,
- type: "Note",
- visibility: "public",
- actorId: author.actor.id,
- noteSourceId,
contentHtml: "Hello world
",
- language: "en",
- reactionsCounts: { "❤️": 2 },
- url: `http://localhost/@author/${noteSourceId}`,
published: timestamp,
updated: timestamp,
+ reactionsCounts: { "❤️": 2 },
});
await tx.insert(reactionTable).values([
{
iri: `http://localhost/reactions/${generateUuidV7()}`,
- postId: noteId,
+ postId: post.id,
actorId: viewer.actor.id,
emoji: "❤️",
created: new Date("2026-04-15T00:00:01.000Z"),
},
{
iri: `http://localhost/reactions/${generateUuidV7()}`,
- postId: noteId,
+ postId: post.id,
actorId: other.actor.id,
emoji: "❤️",
created: new Date("2026-04-15T00:00:02.000Z"),
@@ -180,93 +142,8 @@ async function seedReactedNote(tx: Transaction) {
]);
return {
- noteId,
+ noteId: post.id,
viewerAccount: viewer.account,
reactorIds: [viewer.actor.id, other.actor.id],
};
}
-
-async function insertAccountWithActor(
- tx: Transaction,
- values: {
- username: string;
- name: string;
- email: string;
- iri: string;
- inboxUrl: string;
- },
-) {
- const accountId = generateUuidV7();
- const actorId = generateUuidV7();
- const timestamp = new Date("2026-04-15T00:00:00.000Z");
-
- await tx.insert(accountTable).values({
- id: accountId,
- username: values.username,
- name: values.name,
- bio: "",
- leftInvitations: 0,
- created: timestamp,
- updated: timestamp,
- });
-
- await tx.insert(accountEmailTable).values({
- email: values.email,
- accountId,
- public: false,
- verified: timestamp,
- created: timestamp,
- });
-
- await tx.insert(actorTable).values({
- id: actorId,
- iri: values.iri,
- type: "Person",
- username: values.username,
- instanceHost: "localhost",
- handleHost: "localhost",
- accountId,
- name: values.name,
- inboxUrl: values.inboxUrl,
- sharedInboxUrl: "http://localhost/inbox",
- created: timestamp,
- updated: timestamp,
- published: timestamp,
- });
-
- const account = await tx.query.accountTable.findFirst({
- where: { id: accountId },
- with: {
- actor: true,
- emails: true,
- links: true,
- },
- });
-
- assert(account != null);
-
- return {
- account: account as AuthenticatedAccount,
- actor: account.actor,
- };
-}
-
-function makeContext(
- tx: Transaction,
- account: AuthenticatedAccount,
-): UserContext {
- return {
- db: tx,
- kv: {} as UserContext["kv"],
- disk: {} as UserContext["disk"],
- email: {} as UserContext["email"],
- fedCtx: {} as UserContext["fedCtx"],
- request: new Request("http://localhost/graphql"),
- session: {
- id: generateUuidV7(),
- accountId: account.id,
- created: new Date("2026-04-15T00:00:00.000Z"),
- },
- account,
- };
-}
diff --git a/graphql/search.post.test.ts b/graphql/search.post.test.ts
new file mode 100644
index 00000000..0c5ca443
--- /dev/null
+++ b/graphql/search.post.test.ts
@@ -0,0 +1,141 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { encodeGlobalID } from "@pothos/plugin-relay";
+import { eq } from "drizzle-orm";
+import { execute, parse } from "graphql";
+import { accountTable } from "@hackerspub/models/schema";
+import { schema } from "./mod.ts";
+import {
+ insertAccountWithActor,
+ insertNotePost,
+ makeGuestContext,
+ makeUserContext,
+ toPlainJson,
+ withRollback,
+} from "../test/postgres.ts";
+
+const searchPostQuery = parse(`
+ query SearchPost($query: String!, $languages: [Locale!], $first: Int) {
+ searchPost(query: $query, languages: $languages, first: $first) {
+ edges {
+ node {
+ id
+ }
+ }
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ }
+ }
+ }
+`);
+
+test("searchPost returns matching public posts and respects language filters", async () => {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "searchpostauthor",
+ name: "Search Post Author",
+ email: "searchpostauthor@example.com",
+ });
+ const { post: english } = await insertNotePost(tx, {
+ account: author.account,
+ contentHtml: "Relay search target
",
+ language: "en",
+ });
+ await insertNotePost(tx, {
+ account: author.account,
+ contentHtml: "Relay Japanese target
",
+ language: "ja",
+ });
+
+ const allResults = await execute({
+ schema,
+ document: searchPostQuery,
+ variableValues: { query: "Relay", first: 10 },
+ contextValue: makeGuestContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+ assert.equal(allResults.errors, undefined);
+ const allIds = (toPlainJson(allResults.data) as {
+ searchPost: { edges: Array<{ node: { id: string } }> };
+ }).searchPost.edges.map((edge) => edge.node.id);
+ assert.ok(allIds.includes(encodeGlobalID("Note", english.id)));
+ assert.equal(allIds.length, 2);
+
+ const filteredResults = await execute({
+ schema,
+ document: searchPostQuery,
+ variableValues: { query: "Relay", languages: ["en"], first: 10 },
+ contextValue: makeGuestContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+ assert.equal(filteredResults.errors, undefined);
+ assert.deepEqual(toPlainJson(filteredResults.data), {
+ searchPost: {
+ edges: [{ node: { id: encodeGlobalID("Note", english.id) } }],
+ pageInfo: { hasNextPage: false, hasPreviousPage: false },
+ },
+ });
+ });
+});
+
+test("searchPost rejects invalid search syntax and respects hidden foreign languages", async () => {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "searchpostviewer",
+ name: "Search Post Viewer",
+ email: "searchpostviewer@example.com",
+ });
+ await tx.update(accountTable)
+ .set({ hideForeignLanguages: true, locales: ["ko"] })
+ .where(eq(accountTable.id, account.account.id));
+
+ const author = await insertAccountWithActor(tx, {
+ username: "searchpostlangauthor",
+ name: "Search Post Lang Author",
+ email: "searchpostlangauthor@example.com",
+ });
+ await insertNotePost(tx, {
+ account: author.account,
+ contentHtml: "Hidden English result
",
+ language: "en",
+ });
+ const { post: korean } = await insertNotePost(tx, {
+ account: author.account,
+ contentHtml: "Visible Korean result
",
+ language: "ko",
+ });
+
+ const visibleResults = await execute({
+ schema,
+ document: searchPostQuery,
+ variableValues: { query: "result", first: 10 },
+ contextValue: makeUserContext(tx, {
+ ...account.account,
+ hideForeignLanguages: true,
+ locales: ["ko"],
+ }),
+ onError: "NO_PROPAGATE",
+ });
+ assert.equal(visibleResults.errors, undefined);
+ assert.deepEqual(toPlainJson(visibleResults.data), {
+ searchPost: {
+ edges: [{ node: { id: encodeGlobalID("Note", korean.id) } }],
+ pageInfo: { hasNextPage: false, hasPreviousPage: false },
+ },
+ });
+
+ const invalidQuery = await execute({
+ schema,
+ document: searchPostQuery,
+ variableValues: { query: "(", first: 10 },
+ contextValue: makeGuestContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+ assert.deepEqual(toPlainJson(invalidQuery.data), { searchPost: null });
+ assert.equal(
+ invalidQuery.errors?.[0].message,
+ "Invalid search query format",
+ );
+ });
+});
diff --git a/graphql/search.test.ts b/graphql/search.test.ts
new file mode 100644
index 00000000..6cf02c6b
--- /dev/null
+++ b/graphql/search.test.ts
@@ -0,0 +1,103 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { execute, parse } from "graphql";
+import { schema } from "./mod.ts";
+import {
+ insertAccountWithActor,
+ insertNotePost,
+ makeGuestContext,
+ toPlainJson,
+ withRollback,
+} from "../test/postgres.ts";
+
+const searchObjectQuery = parse(`
+ query SearchObject($query: String!) {
+ searchObject(query: $query) {
+ __typename
+ ... on SearchedObject {
+ url
+ }
+ ... on EmptySearchQueryError {
+ message
+ }
+ }
+ }
+`);
+
+test("searchObject returns an error union for empty queries", async () => {
+ await withRollback(async (tx) => {
+ const result = await execute({
+ schema,
+ document: searchObjectQuery,
+ variableValues: { query: " " },
+ contextValue: makeGuestContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+ assert.deepEqual(toPlainJson(result.data), {
+ searchObject: {
+ __typename: "EmptySearchQueryError",
+ message: "Query cannot be empty",
+ },
+ });
+ });
+});
+
+test("searchObject resolves local handles without federation lookup", async () => {
+ await withRollback(async (tx) => {
+ await insertAccountWithActor(tx, {
+ username: "searchhandle",
+ name: "Search Handle",
+ email: "searchhandle@example.com",
+ });
+
+ const result = await execute({
+ schema,
+ document: searchObjectQuery,
+ variableValues: { query: "@searchhandle" },
+ contextValue: makeGuestContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+ assert.deepEqual(toPlainJson(result.data), {
+ searchObject: {
+ __typename: "SearchedObject",
+ url: "/@searchhandle",
+ },
+ });
+ });
+});
+
+test("searchObject resolves local note URLs to canonical note routes", async () => {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "searchnote",
+ name: "Search Note",
+ email: "searchnote@example.com",
+ });
+ const { noteSourceId } = await insertNotePost(tx, {
+ account: account.account,
+ content: "Searchable note",
+ });
+
+ const result = await execute({
+ schema,
+ document: searchObjectQuery,
+ variableValues: {
+ query: `http://localhost/@${account.account.username}/${noteSourceId}`,
+ },
+ contextValue: makeGuestContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+ assert.deepEqual(toPlainJson(result.data), {
+ searchObject: {
+ __typename: "SearchedObject",
+ url: `/@${account.account.username}/${noteSourceId}`,
+ },
+ });
+ });
+});
diff --git a/graphql/signup.more.test.ts b/graphql/signup.more.test.ts
new file mode 100644
index 00000000..9ce765c3
--- /dev/null
+++ b/graphql/signup.more.test.ts
@@ -0,0 +1,134 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { execute, parse } from "graphql";
+import { createSignupToken } from "@hackerspub/models/signup";
+import { schema } from "./mod.ts";
+import {
+ createTestKv,
+ insertAccountWithActor,
+ makeGuestContext,
+ toPlainJson,
+ withRollback,
+} from "../test/postgres.ts";
+
+const verifySignupTokenQuery = parse(`
+ query VerifySignupToken($token: UUID!, $code: String!) {
+ verifySignupToken(token: $token, code: $code) {
+ email
+ }
+ }
+`);
+
+const completeSignupMutation = parse(`
+ mutation CompleteSignup($token: UUID!, $code: String!, $input: SignupInput!) {
+ completeSignup(token: $token, code: $code, input: $input) {
+ __typename
+ ... on SignupValidationErrors {
+ username
+ name
+ bio
+ }
+ ... on Session {
+ id
+ }
+ }
+ }
+`);
+
+test("verifySignupToken returns null for wrong codes and already-registered emails", async () => {
+ await withRollback(async (tx) => {
+ const { kv } = createTestKv();
+ const token = await createSignupToken(kv, "verify-me@example.com");
+
+ const wrongCode = await execute({
+ schema,
+ document: verifySignupTokenQuery,
+ variableValues: { token: token.token, code: "wrong" },
+ contextValue: makeGuestContext(tx, { kv }),
+ onError: "NO_PROPAGATE",
+ });
+ assert.equal(wrongCode.errors, undefined);
+ assert.deepEqual(toPlainJson(wrongCode.data), { verifySignupToken: null });
+
+ await insertAccountWithActor(tx, {
+ username: "registeredemail",
+ name: "Registered Email",
+ email: "verify-me@example.com",
+ });
+
+ const alreadyRegistered = await execute({
+ schema,
+ document: verifySignupTokenQuery,
+ variableValues: { token: token.token, code: token.code },
+ contextValue: makeGuestContext(tx, { kv }),
+ onError: "NO_PROPAGATE",
+ });
+ assert.equal(alreadyRegistered.errors, undefined);
+ assert.deepEqual(toPlainJson(alreadyRegistered.data), {
+ verifySignupToken: null,
+ });
+ });
+});
+
+test("completeSignup reports invalid token, invalid code, and duplicate email errors", async () => {
+ await withRollback(async (tx) => {
+ const { kv } = createTestKv();
+ const token = await createSignupToken(kv, "duplicate@example.com");
+
+ const invalidToken = await execute({
+ schema,
+ document: completeSignupMutation,
+ variableValues: {
+ token: "019d9162-ffff-7fff-8fff-ffffffffffff",
+ code: token.code,
+ input: { username: "newuser", name: "New User", bio: "Bio" },
+ },
+ contextValue: makeGuestContext(tx, { kv }),
+ onError: "NO_PROPAGATE",
+ });
+ assert.deepEqual(toPlainJson(invalidToken.data), { completeSignup: null });
+ assert.equal(
+ invalidToken.errors?.[0].message,
+ "Invalid or expired signup token",
+ );
+
+ const invalidCode = await execute({
+ schema,
+ document: completeSignupMutation,
+ variableValues: {
+ token: token.token,
+ code: "wrong-code",
+ input: { username: "newuser", name: "New User", bio: "Bio" },
+ },
+ contextValue: makeGuestContext(tx, { kv }),
+ onError: "NO_PROPAGATE",
+ });
+ assert.deepEqual(toPlainJson(invalidCode.data), { completeSignup: null });
+ assert.equal(invalidCode.errors?.[0].message, "Invalid verification code");
+
+ await insertAccountWithActor(tx, {
+ username: "duplicateowner",
+ name: "Duplicate Owner",
+ email: "duplicate@example.com",
+ });
+
+ const duplicateEmail = await execute({
+ schema,
+ document: completeSignupMutation,
+ variableValues: {
+ token: token.token,
+ code: token.code,
+ input: { username: "newuser", name: "New User", bio: "Bio" },
+ },
+ contextValue: makeGuestContext(tx, { kv }),
+ onError: "NO_PROPAGATE",
+ });
+ assert.deepEqual(toPlainJson(duplicateEmail.data), {
+ completeSignup: null,
+ });
+ assert.equal(
+ duplicateEmail.errors?.[0].message,
+ "Email is already registered",
+ );
+ });
+});
diff --git a/graphql/signup.test.ts b/graphql/signup.test.ts
new file mode 100644
index 00000000..24757811
--- /dev/null
+++ b/graphql/signup.test.ts
@@ -0,0 +1,226 @@
+import { assert } from "@std/assert/assert";
+import { assertEquals } from "@std/assert/equals";
+import { encodeGlobalID } from "@pothos/plugin-relay";
+import { execute, parse } from "graphql";
+import { getSession } from "@hackerspub/models/session";
+import { createSignupToken, getSignupToken } from "@hackerspub/models/signup";
+import { schema } from "./mod.ts";
+import {
+ createTestKv,
+ insertAccountWithActor,
+ makeGuestContext,
+ withRollback,
+} from "../test/postgres.ts";
+
+const verifySignupTokenQuery = parse(`
+ query VerifySignupToken($token: UUID!, $code: String!) {
+ verifySignupToken(token: $token, code: $code) {
+ email
+ inviter {
+ id
+ }
+ }
+ }
+`);
+
+const completeSignupMutation = parse(`
+ mutation CompleteSignup(
+ $token: UUID!
+ $code: String!
+ $input: SignupInput!
+ ) {
+ completeSignup(token: $token, code: $code, input: $input) {
+ __typename
+ ... on Session {
+ id
+ account {
+ id
+ username
+ }
+ }
+ ... on SignupValidationErrors {
+ username
+ name
+ bio
+ }
+ }
+ }
+`);
+
+Deno.test({
+ name: "verifySignupToken returns signup info for a valid token",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const { kv } = createTestKv();
+ const inviter = await insertAccountWithActor(tx, {
+ username: "signupinviter",
+ name: "Signup Inviter",
+ email: "signupinviter@example.com",
+ });
+ const signupToken = await createSignupToken(kv, "new@example.com", {
+ inviterId: inviter.account.id,
+ });
+
+ const result = await execute({
+ schema,
+ document: verifySignupTokenQuery,
+ variableValues: {
+ token: signupToken.token,
+ code: signupToken.code,
+ },
+ contextValue: makeGuestContext(tx, { kv }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(result.errors, undefined);
+ assertEquals(
+ (result.data as {
+ verifySignupToken: {
+ email: string;
+ inviter: { id: string } | null;
+ } | null;
+ }).verifySignupToken,
+ {
+ email: "new@example.com",
+ inviter: { id: encodeGlobalID("Account", inviter.account.id) },
+ },
+ );
+ });
+ },
+});
+
+Deno.test({
+ name: "completeSignup returns validation errors for a taken username",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const { kv } = createTestKv();
+ await insertAccountWithActor(tx, {
+ username: "takenuser",
+ name: "Taken User",
+ email: "taken@example.com",
+ });
+ const signupToken = await createSignupToken(kv, "candidate@example.com");
+
+ const result = await execute({
+ schema,
+ document: completeSignupMutation,
+ variableValues: {
+ token: signupToken.token,
+ code: signupToken.code,
+ input: {
+ username: "takenuser",
+ name: "Candidate",
+ bio: "Hello",
+ },
+ },
+ contextValue: makeGuestContext(tx, { kv }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(result.errors, undefined);
+ assertEquals(
+ (result.data as {
+ completeSignup: {
+ __typename: string;
+ username?: string | null;
+ name?: string | null;
+ bio?: string | null;
+ };
+ }).completeSignup,
+ {
+ __typename: "SignupValidationErrors",
+ username: "USERNAME_ALREADY_TAKEN",
+ name: null,
+ bio: null,
+ },
+ );
+ });
+ },
+});
+
+Deno.test({
+ name: "completeSignup creates an account, session, and inviter follows",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const { kv } = createTestKv();
+ const inviter = await insertAccountWithActor(tx, {
+ username: "completeinviter",
+ name: "Complete Inviter",
+ email: "completeinviter@example.com",
+ });
+ const signupToken = await createSignupToken(kv, "fresh@example.com", {
+ inviterId: inviter.account.id,
+ });
+
+ const result = await execute({
+ schema,
+ document: completeSignupMutation,
+ variableValues: {
+ token: signupToken.token,
+ code: signupToken.code,
+ input: {
+ username: "freshuser",
+ name: "Fresh User",
+ bio: "Fresh bio",
+ },
+ },
+ contextValue: makeGuestContext(tx, {
+ kv,
+ request: new Request("http://localhost/graphql", {
+ headers: { "user-agent": "signup-test" },
+ }),
+ }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(result.errors, undefined);
+
+ const sessionPayload = (result.data as {
+ completeSignup: {
+ __typename: string;
+ id?: string;
+ account?: { id: string; username: string };
+ };
+ }).completeSignup;
+ assertEquals(sessionPayload.__typename, "Session");
+ assertEquals(sessionPayload.account?.username, "freshuser");
+ assert(sessionPayload.id != null);
+ const sessionId = sessionPayload
+ .id as `${string}-${string}-${string}-${string}-${string}`;
+
+ const account = await tx.query.accountTable.findFirst({
+ where: { username: "freshuser" },
+ with: { actor: true, emails: true },
+ });
+ assert(account != null);
+ assertEquals(account.inviterId, inviter.account.id);
+ assertEquals(account.emails.map((email) => email.email), [
+ "fresh@example.com",
+ ]);
+
+ const storedSession = await getSession(kv, sessionId);
+ assertEquals(storedSession?.accountId, account.id);
+ assertEquals(storedSession?.userAgent, "signup-test");
+
+ const storedToken = await getSignupToken(kv, signupToken.token);
+ assertEquals(storedToken, undefined);
+
+ const followings = await tx.query.followingTable.findMany({
+ where: {
+ OR: [
+ { followerId: account.actor.id, followeeId: inviter.actor.id },
+ { followerId: inviter.actor.id, followeeId: account.actor.id },
+ ],
+ },
+ });
+ assertEquals(followings.length, 2);
+ assert(followings.every((following) => following.accepted != null));
+ });
+ },
+});
diff --git a/graphql/timeline.test.ts b/graphql/timeline.test.ts
new file mode 100644
index 00000000..68dfe806
--- /dev/null
+++ b/graphql/timeline.test.ts
@@ -0,0 +1,249 @@
+import { assertEquals } from "@std/assert/equals";
+import { eq } from "drizzle-orm";
+import { encodeGlobalID } from "@pothos/plugin-relay";
+import { execute, parse } from "graphql";
+import { follow } from "@hackerspub/models/following";
+import { sharePost } from "@hackerspub/models/post";
+import { postTable } from "@hackerspub/models/schema";
+import { schema } from "./mod.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ insertNotePost,
+ insertRemoteActor,
+ insertRemotePost,
+ makeUserContext,
+ withRollback,
+} from "../test/postgres.ts";
+
+const publicTimelineQuery = parse(`
+ query PublicTimelineTest(
+ $first: Int
+ $local: Boolean
+ $withoutShares: Boolean
+ ) {
+ publicTimeline(
+ first: $first
+ local: $local
+ withoutShares: $withoutShares
+ ) {
+ pageInfo {
+ hasNextPage
+ }
+ edges {
+ node {
+ id
+ }
+ }
+ }
+ }
+`);
+
+const personalTimelineQuery = parse(`
+ query PersonalTimelineTest($withoutShares: Boolean) {
+ personalTimeline(first: 10, withoutShares: $withoutShares) {
+ edges {
+ node {
+ id
+ }
+ lastSharer {
+ id
+ }
+ sharersCount
+ }
+ }
+ }
+`);
+
+Deno.test({
+ name: "publicTimeline exposes forward pagination metadata",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const localAuthor = await insertAccountWithActor(tx, {
+ username: "graphqltimelineauthor",
+ name: "GraphQL Timeline Author",
+ email: "graphqltimelineauthor@example.com",
+ });
+ const { post: localPost } = await insertNotePost(tx, {
+ account: localAuthor.account,
+ content: "Local timeline post",
+ });
+ const remoteActor = await insertRemoteActor(tx, {
+ username: "graphqltimeremote",
+ name: "GraphQL Timeline Remote",
+ host: "graphql.timeline.example",
+ });
+ const remotePost = await insertRemotePost(tx, {
+ actorId: remoteActor.id,
+ contentHtml: "Remote timeline post
",
+ });
+
+ await tx.update(postTable)
+ .set({
+ published: new Date("2026-04-15T00:00:01.000Z"),
+ updated: new Date("2026-04-15T00:00:01.000Z"),
+ })
+ .where(eq(postTable.id, localPost.id));
+ await tx.update(postTable)
+ .set({
+ published: new Date("2026-04-15T00:00:02.000Z"),
+ updated: new Date("2026-04-15T00:00:02.000Z"),
+ })
+ .where(eq(postTable.id, remotePost.id));
+
+ const result = await execute({
+ schema,
+ document: publicTimelineQuery,
+ variableValues: { first: 1 },
+ contextValue: makeUserContext(tx, localAuthor.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(result.errors, undefined);
+
+ const connection = (result.data as {
+ publicTimeline: {
+ pageInfo: { hasNextPage: boolean };
+ edges: { node: { id: string } }[];
+ };
+ }).publicTimeline;
+
+ assertEquals(connection.pageInfo.hasNextPage, true);
+ assertEquals(connection.edges.map((edge) => edge.node.id), [
+ encodeGlobalID("Note", remotePost.id),
+ ]);
+ });
+ },
+});
+
+Deno.test({
+ name: "publicTimeline and personalTimeline honor share filters",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const localAuthor = await insertAccountWithActor(tx, {
+ username: "graphqllocalfilterauthor",
+ name: "GraphQL Local Filter Author",
+ email: "graphqllocalfilterauthor@example.com",
+ });
+ const sharer = await insertAccountWithActor(tx, {
+ username: "graphqltimelinefiltersharer",
+ name: "GraphQL Timeline Filter Sharer",
+ email: "graphqltimelinefiltersharer@example.com",
+ });
+ const viewer = await insertAccountWithActor(tx, {
+ username: "graphqltimelinefilterviewer",
+ name: "GraphQL Timeline Filter Viewer",
+ email: "graphqltimelinefilterviewer@example.com",
+ });
+ const { post: localPost } = await insertNotePost(tx, {
+ account: localAuthor.account,
+ content: "Filtered local post",
+ });
+ const remoteActor = await insertRemoteActor(tx, {
+ username: "graphqltimelinefilterremote",
+ name: "GraphQL Timeline Filter Remote",
+ host: "timeline-filter.example",
+ });
+ const remotePost = await insertRemotePost(tx, {
+ actorId: remoteActor.id,
+ contentHtml: "Filtered remote post
",
+ });
+
+ await follow(fedCtx, viewer.account, sharer.actor);
+ const share = await sharePost(fedCtx, sharer.account, {
+ ...remotePost,
+ actor: remoteActor,
+ });
+
+ await tx.update(postTable)
+ .set({
+ published: new Date("2026-04-15T00:00:01.000Z"),
+ updated: new Date("2026-04-15T00:00:01.000Z"),
+ })
+ .where(eq(postTable.id, localPost.id));
+ await tx.update(postTable)
+ .set({
+ published: new Date("2026-04-15T00:00:02.000Z"),
+ updated: new Date("2026-04-15T00:00:02.000Z"),
+ })
+ .where(eq(postTable.id, remotePost.id));
+ await tx.update(postTable)
+ .set({
+ published: new Date("2026-04-15T00:00:03.000Z"),
+ updated: new Date("2026-04-15T00:00:03.000Z"),
+ })
+ .where(eq(postTable.id, share.id));
+
+ const publicResult = await execute({
+ schema,
+ document: publicTimelineQuery,
+ variableValues: {
+ first: 10,
+ local: true,
+ withoutShares: true,
+ },
+ contextValue: makeUserContext(tx, viewer.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(publicResult.errors, undefined);
+ assertEquals(
+ (publicResult.data as {
+ publicTimeline: { edges: { node: { id: string } }[] };
+ }).publicTimeline.edges.map((edge) => edge.node.id),
+ [encodeGlobalID("Note", localPost.id)],
+ );
+
+ const personalResult = await execute({
+ schema,
+ document: personalTimelineQuery,
+ variableValues: { withoutShares: false },
+ contextValue: makeUserContext(tx, viewer.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(personalResult.errors, undefined);
+
+ const personalEdges = (personalResult.data as {
+ personalTimeline: {
+ edges: {
+ node: { id: string };
+ lastSharer: { id: string } | null;
+ sharersCount: number;
+ }[];
+ };
+ }).personalTimeline.edges;
+ assertEquals(personalEdges.length, 1);
+ assertEquals(
+ personalEdges[0].node.id,
+ encodeGlobalID("Note", remotePost.id),
+ );
+ assertEquals(
+ personalEdges[0].lastSharer?.id,
+ encodeGlobalID("Actor", sharer.actor.id),
+ );
+ assertEquals(personalEdges[0].sharersCount, 1);
+
+ const withoutSharesResult = await execute({
+ schema,
+ document: personalTimelineQuery,
+ variableValues: { withoutShares: true },
+ contextValue: makeUserContext(tx, viewer.account),
+ onError: "NO_PROPAGATE",
+ });
+
+ assertEquals(withoutSharesResult.errors, undefined);
+ assertEquals(
+ (withoutSharesResult.data as {
+ personalTimeline: { edges: unknown[] };
+ }).personalTimeline.edges,
+ [],
+ );
+ });
+ },
+});
diff --git a/graphql/webfinger.test.ts b/graphql/webfinger.test.ts
new file mode 100644
index 00000000..be957764
--- /dev/null
+++ b/graphql/webfinger.test.ts
@@ -0,0 +1,101 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { encodeGlobalID } from "@pothos/plugin-relay";
+import { execute, parse } from "graphql";
+import { schema } from "./mod.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ makeGuestContext,
+ toPlainJson,
+ withRollback,
+} from "../test/postgres.ts";
+
+const lookupRemoteFollowerQuery = parse(`
+ query LookupRemoteFollower($actorId: ID!, $followerHandle: String!) {
+ lookupRemoteFollower(actorId: $actorId, followerHandle: $followerHandle) {
+ preferredUsername
+ handle
+ domain
+ software
+ url
+ remoteFollowUrl
+ }
+ }
+`);
+
+test("lookupRemoteFollower builds a fallback result from WebFinger data", async () => {
+ await withRollback(async (tx) => {
+ const actor = await insertAccountWithActor(tx, {
+ username: "lookupactor",
+ name: "Lookup Actor",
+ email: "lookupactor@example.com",
+ });
+ const fedCtx = createFedCtx(tx);
+ fedCtx.lookupWebFinger = () =>
+ Promise.resolve({
+ links: [
+ {
+ rel: "self",
+ type: "application/activity+json",
+ href: "https://remote.example/users/alice",
+ },
+ {
+ rel: "http://ostatus.org/schema/1.0/subscribe",
+ template: "https://remote.example/authorize?uri={uri}",
+ },
+ ],
+ });
+ fedCtx.lookupObject = () => Promise.reject(new Error("lookup failed"));
+ fedCtx.getDocumentLoader = () => Promise.resolve({}) as never;
+
+ const result = await execute({
+ schema,
+ document: lookupRemoteFollowerQuery,
+ variableValues: {
+ actorId: encodeGlobalID("Actor", actor.actor.id),
+ followerHandle: "@alice@remote.example",
+ },
+ contextValue: makeGuestContext(tx, { fedCtx }),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+ assert.deepEqual(toPlainJson(result.data), {
+ lookupRemoteFollower: {
+ preferredUsername: "alice",
+ handle: "alice@remote.example",
+ domain: "remote.example",
+ software: "unknown",
+ url: "https://remote.example/users/alice",
+ remoteFollowUrl: `https://remote.example/authorize?uri=${
+ encodeURIComponent(actor.actor.handle)
+ }`,
+ },
+ });
+ });
+});
+
+test("lookupRemoteFollower returns null for invalid handles", async () => {
+ await withRollback(async (tx) => {
+ const actor = await insertAccountWithActor(tx, {
+ username: "invalidlookupactor",
+ name: "Invalid Lookup Actor",
+ email: "invalidlookupactor@example.com",
+ });
+
+ const result = await execute({
+ schema,
+ document: lookupRemoteFollowerQuery,
+ variableValues: {
+ actorId: encodeGlobalID("Actor", actor.actor.id),
+ followerHandle: "not-a-fediverse-handle",
+ },
+ contextValue: makeGuestContext(tx),
+ onError: "NO_PROPAGATE",
+ });
+
+ assert.equal(result.errors, undefined);
+ assert.deepEqual(toPlainJson(result.data), { lookupRemoteFollower: null });
+ });
+});
diff --git a/models/account.db.test.ts b/models/account.db.test.ts
new file mode 100644
index 00000000..e7dc7694
--- /dev/null
+++ b/models/account.db.test.ts
@@ -0,0 +1,159 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ getAccountByUsername,
+ getRelationship,
+ updateAccountData,
+} from "./account.ts";
+import { block } from "./blocking.ts";
+import { follow } from "./following.ts";
+import { followingTable } from "./schema.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ insertRemoteActor,
+ withRollback,
+} from "../test/postgres.ts";
+
+test("getAccountByUsername() resolves current and previous usernames", async () => {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "lookupaccount",
+ name: "Lookup Account",
+ email: "lookupaccount@example.com",
+ });
+
+ const updated = await updateAccountData(tx, {
+ id: account.account.id,
+ username: "renamedaccount",
+ name: "Renamed Account",
+ });
+
+ assert.ok(updated != null);
+ assert.equal(updated.username, "renamedaccount");
+ assert.equal(updated.oldUsername, "lookupaccount");
+ assert.ok(updated.usernameChanged != null);
+
+ const current = await getAccountByUsername(tx, "renamedaccount");
+ const previous = await getAccountByUsername(tx, "lookupaccount");
+
+ assert.ok(current != null);
+ assert.ok(previous != null);
+ assert.equal(current.id, account.account.id);
+ assert.equal(previous.id, account.account.id);
+ assert.equal(previous.username, "renamedaccount");
+ assert.equal(previous.actor.id, account.actor.id);
+ assert.deepEqual(
+ previous.emails.map((email) => email.email),
+ ["lookupaccount@example.com"],
+ );
+ });
+});
+
+test("getRelationship() reports follow, request, and block states", async () => {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const viewer = await insertAccountWithActor(tx, {
+ username: "vieweraccount",
+ name: "Viewer Account",
+ email: "vieweraccount@example.com",
+ });
+ const localTarget = await insertAccountWithActor(tx, {
+ username: "localtarget",
+ name: "Local Target",
+ email: "localtarget@example.com",
+ });
+ const blocker = await insertAccountWithActor(tx, {
+ username: "blockeraccount",
+ name: "Blocker Account",
+ email: "blockeraccount@example.com",
+ });
+ const remoteTarget = await insertRemoteActor(tx, {
+ username: "remotetarget",
+ name: "Remote Target",
+ host: "remote.example",
+ });
+ const remoteFollower = await insertRemoteActor(tx, {
+ username: "remotefollower",
+ name: "Remote Follower",
+ host: "followers.example",
+ });
+
+ assert.equal(await getRelationship(tx, null, localTarget.actor), null);
+ assert.equal(await getRelationship(tx, viewer.account, viewer.actor), null);
+
+ const none = await getRelationship(tx, viewer.account, localTarget.actor);
+ assert.deepEqual(
+ none == null ? null : {
+ outgoing: none.outgoing,
+ incoming: none.incoming,
+ },
+ { outgoing: "none", incoming: "none" },
+ );
+
+ await follow(fedCtx, viewer.account, localTarget.actor);
+ await follow(fedCtx, localTarget.account, viewer.actor);
+
+ const mutualFollow = await getRelationship(
+ tx,
+ viewer.account,
+ localTarget.actor,
+ );
+ assert.deepEqual(
+ mutualFollow == null ? null : {
+ outgoing: mutualFollow.outgoing,
+ incoming: mutualFollow.incoming,
+ },
+ { outgoing: "follow", incoming: "follow" },
+ );
+
+ await follow(fedCtx, viewer.account, remoteTarget);
+
+ const outgoingRequest = await getRelationship(
+ tx,
+ viewer.account,
+ remoteTarget,
+ );
+ assert.deepEqual(
+ outgoingRequest == null ? null : {
+ outgoing: outgoingRequest.outgoing,
+ incoming: outgoingRequest.incoming,
+ },
+ { outgoing: "request", incoming: "none" },
+ );
+
+ await tx.insert(followingTable).values({
+ iri: "https://followers.example/follows/remotefollower",
+ followerId: remoteFollower.id,
+ followeeId: viewer.actor.id,
+ });
+
+ const incomingRequest = await getRelationship(
+ tx,
+ viewer.account,
+ remoteFollower,
+ );
+ assert.deepEqual(
+ incomingRequest == null ? null : {
+ outgoing: incomingRequest.outgoing,
+ incoming: incomingRequest.incoming,
+ },
+ { outgoing: "none", incoming: "request" },
+ );
+
+ await block(fedCtx, blocker.account, viewer.actor);
+
+ const incomingBlock = await getRelationship(
+ tx,
+ viewer.account,
+ blocker.actor,
+ );
+ assert.deepEqual(
+ incomingBlock == null ? null : {
+ outgoing: incomingBlock.outgoing,
+ incoming: incomingBlock.incoming,
+ },
+ { outgoing: "none", incoming: "block" },
+ );
+ });
+});
diff --git a/models/account.more.test.ts b/models/account.more.test.ts
new file mode 100644
index 00000000..cf57257c
--- /dev/null
+++ b/models/account.more.test.ts
@@ -0,0 +1,127 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import sharp from "sharp";
+import {
+ getAvatarUrl,
+ transformAvatar,
+ updateAccountLinks,
+ verifyAccountLink,
+} from "./account.ts";
+import {
+ insertAccountWithActor,
+ withMockFetch,
+ withRollback,
+} from "../test/postgres.ts";
+
+test("getAvatarUrl() prefers stored avatars and falls back to gravatar defaults", async () => {
+ const disk = {
+ getUrl(key: string) {
+ return Promise.resolve(`http://localhost/media/${key}`);
+ },
+ };
+
+ const stored = await getAvatarUrl(disk as never, {
+ avatarKey: "avatars/existing.webp",
+ emails: [],
+ } as never);
+ assert.equal(stored, "http://localhost/media/avatars/existing.webp");
+
+ const fallback = await getAvatarUrl(disk as never, {
+ avatarKey: null,
+ emails: [],
+ } as never);
+ assert.equal(fallback, "https://gravatar.com/avatar/?d=mp&s=128");
+});
+
+test("transformAvatar() crops rectangular images and preserves alpha via webp", async () => {
+ const input = await sharp({
+ create: {
+ width: 200,
+ height: 100,
+ channels: 4,
+ background: { r: 255, g: 0, b: 0, alpha: 0.5 },
+ },
+ }).png().toBuffer();
+
+ const { buffer, format } = await transformAvatar(input);
+
+ assert.equal(format, "webp");
+ const metadata = await sharp(buffer).metadata();
+ assert.equal(metadata.width, 100);
+ assert.equal(metadata.height, 100);
+ assert.equal(metadata.format, "webp");
+});
+
+test("verifyAccountLink() recognizes rel=me links pointing at the profile URL", async () => {
+ await withMockFetch(async () => {
+ return new Response(
+ ``,
+ { status: 200, headers: { "Content-Type": "text/html" } },
+ );
+ }, async () => {
+ const verified = await verifyAccountLink(
+ "https://example.com/profile",
+ "https://hackers.pub/@alice",
+ );
+ assert.equal(verified, true);
+ });
+});
+
+test("updateAccountLinks() stores ordered links with metadata and verification", async () => {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "accountlinksowner",
+ name: "Account Links Owner",
+ email: "accountlinksowner@example.com",
+ });
+
+ await withMockFetch(async (input) => {
+ const url = typeof input === "string"
+ ? input
+ : input instanceof URL
+ ? input.href
+ : input.url;
+ return new Response(
+ `me${url}`,
+ { status: 200, headers: { "Content-Type": "text/html" } },
+ );
+ }, async () => {
+ const links = await updateAccountLinks(
+ tx,
+ account.account.id,
+ "https://hackers.pub/@accountlinksowner",
+ [
+ { name: "GitHub", url: "https://github.com/dahlia" },
+ { name: "Codeberg", url: "https://codeberg.org/hongminhee" },
+ ],
+ );
+
+ assert.equal(links.length, 2);
+ assert.deepEqual(
+ links.map((link) => ({
+ index: link.index,
+ name: link.name,
+ icon: link.icon,
+ handle: link.handle,
+ verified: link.verified != null,
+ })),
+ [
+ {
+ index: 0,
+ name: "GitHub",
+ icon: "github",
+ handle: "@dahlia",
+ verified: true,
+ },
+ {
+ index: 1,
+ name: "Codeberg",
+ icon: "codeberg",
+ handle: "@hongminhee",
+ verified: true,
+ },
+ ],
+ );
+ });
+ });
+});
diff --git a/models/actor.more.test.ts b/models/actor.more.test.ts
new file mode 100644
index 00000000..fa1af34b
--- /dev/null
+++ b/models/actor.more.test.ts
@@ -0,0 +1,157 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import * as vocab from "@fedify/vocab";
+import {
+ persistActor,
+ persistActorsByHandles,
+ recommendActors,
+ toRecipient,
+} from "./actor.ts";
+import { follow } from "./following.ts";
+import {
+ createFedCtx,
+ createTestKv,
+ insertAccountWithActor,
+ insertNotePost,
+ insertRemoteActor,
+ insertRemotePost,
+ withRollback,
+} from "../test/postgres.ts";
+
+test("persistActor() stores a remote actor and toRecipient() reflects inbox endpoints", async () => {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const actorObject = new vocab.Person({
+ id: new URL("https://remote.example/users/alice"),
+ preferredUsername: "alice",
+ name: "Alice Remote",
+ inbox: new URL("https://remote.example/users/alice/inbox"),
+ followers: new URL("https://remote.example/users/alice/followers"),
+ endpoints: new vocab.Endpoints({
+ sharedInbox: new URL("https://remote.example/inbox"),
+ }),
+ url: new URL("https://remote.example/@alice"),
+ });
+
+ const actor = await persistActor(fedCtx, actorObject, { outbox: false });
+
+ assert.ok(actor != null);
+ assert.equal(actor.username, "alice");
+ assert.equal(actor.instance.host, "remote.example");
+ assert.equal(actor.handle, "@alice@remote.example");
+ assert.equal(actor.account, null);
+
+ const recipient = toRecipient(actor);
+ assert.ok(recipient.id != null);
+ assert.ok(recipient.inboxId != null);
+ assert.equal(recipient.id.href, actor.iri);
+ assert.equal(recipient.inboxId.href, actor.inboxUrl);
+ assert.equal(recipient.endpoints?.sharedInbox?.href, actor.sharedInboxUrl);
+ });
+});
+
+test("persistActorsByHandles() fetches missing handles and returns cached actors on repeat", async () => {
+ await withRollback(async (tx) => {
+ const { kv, store } = createTestKv();
+ const fedCtx = createFedCtx(tx, { kv });
+ let lookups = 0;
+ fedCtx.getDocumentLoader = () => Promise.resolve({}) as never;
+ fedCtx.lookupObject = (handle: string) => {
+ lookups += 1;
+ if (handle !== "@bob@remote.example") return Promise.resolve(null);
+ return Promise.resolve(
+ new vocab.Person({
+ id: new URL("https://remote.example/users/bob"),
+ preferredUsername: "bob",
+ name: "Bob Remote",
+ inbox: new URL("https://remote.example/users/bob/inbox"),
+ endpoints: new vocab.Endpoints({
+ sharedInbox: new URL("https://remote.example/inbox"),
+ }),
+ url: new URL("https://remote.example/@bob"),
+ }),
+ );
+ };
+
+ const first = await persistActorsByHandles(fedCtx, ["@bob@remote.example"]);
+
+ assert.equal(lookups, 1);
+ assert.ok(first["@bob@remote.example"] != null);
+ assert.equal(first["@bob@remote.example"].username, "bob");
+
+ const second = await persistActorsByHandles(fedCtx, [
+ "@bob@remote.example",
+ ]);
+
+ assert.equal(lookups, 1);
+ assert.equal(
+ second["@bob@remote.example"].id,
+ first["@bob@remote.example"].id,
+ );
+
+ store.set("unreachable-handles/@ghost@remote.example", "1");
+ const skipped = await persistActorsByHandles(fedCtx, [
+ "@ghost@remote.example",
+ ]);
+ assert.deepEqual(skipped, {});
+ assert.equal(lookups, 1);
+ });
+});
+
+test("recommendActors() excludes followed actors and prefers matching locales", async () => {
+ await withRollback(async (tx) => {
+ const viewer = await insertAccountWithActor(tx, {
+ username: "recommendviewer",
+ name: "Recommend Viewer",
+ email: "recommendviewer@example.com",
+ });
+ const localCandidate = await insertAccountWithActor(tx, {
+ username: "recommendlocal",
+ name: "Recommend Local",
+ email: "recommendlocal@example.com",
+ });
+ const followedCandidate = await insertAccountWithActor(tx, {
+ username: "recommendfollowed",
+ name: "Recommend Followed",
+ email: "recommendfollowed@example.com",
+ });
+ const remoteCandidate = await insertRemoteActor(tx, {
+ username: "recommendremote",
+ name: "Recommend Remote",
+ host: "remote.example",
+ });
+ await insertRemotePost(tx, {
+ actorId: remoteCandidate.id,
+ language: "ja",
+ contentHtml: "Remote Japanese post
",
+ });
+ await insertNotePost(tx, {
+ account: localCandidate.account,
+ language: "en",
+ content: "Local English post",
+ });
+ await insertNotePost(tx, {
+ account: followedCandidate.account,
+ language: "en",
+ content: "Followed English post",
+ });
+
+ const fedCtx = createFedCtx(tx);
+ await follow(fedCtx, viewer.account, followedCandidate.actor);
+
+ const recommended = await recommendActors(tx, {
+ account: viewer.account,
+ mainLocale: "en-US",
+ locales: ["en-US"],
+ limit: 10,
+ });
+
+ assert.ok(
+ recommended.some((actor) => actor.id === localCandidate.actor.id),
+ );
+ assert.ok(
+ !recommended.some((actor) => actor.id === followedCandidate.actor.id),
+ );
+ assert.ok(!recommended.some((actor) => actor.id === remoteCandidate.id));
+ });
+});
diff --git a/models/actor.test.ts b/models/actor.test.ts
new file mode 100644
index 00000000..7b322698
--- /dev/null
+++ b/models/actor.test.ts
@@ -0,0 +1,122 @@
+import { assert } from "@std/assert/assert";
+import { assertEquals } from "@std/assert/equals";
+import { getActorStats, getPersistedActor } from "./actor.ts";
+import { articleSourceTable, type NewPost, postTable } from "./schema.ts";
+import { generateUuidV7 } from "./uuid.ts";
+import {
+ insertAccountWithActor,
+ insertNotePost,
+ withRollback,
+} from "../test/postgres.ts";
+
+Deno.test({
+ name: "getPersistedActor() loads local actor with account and instance",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "persistedactor",
+ name: "Persisted Actor",
+ email: "persistedactor@example.com",
+ });
+
+ const actor = await getPersistedActor(tx, account.actor.iri);
+
+ assert(actor != null);
+ assertEquals(actor.id, account.actor.id);
+ assertEquals(actor.account?.id, account.account.id);
+ assertEquals(actor.instance.host, "localhost");
+ assertEquals(actor.successor, null);
+ });
+ },
+});
+
+Deno.test({
+ name: "getActorStats() counts notes, replies, shares, and articles",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "actorstats",
+ name: "Actor Stats",
+ email: "actorstats@example.com",
+ });
+ const published = new Date("2026-04-15T00:00:00.000Z");
+ const { post: note } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Original note",
+ published,
+ });
+ await insertNotePost(tx, {
+ account: author.account,
+ content: "Reply note",
+ replyTargetId: note.id,
+ published: new Date("2026-04-15T01:00:00.000Z"),
+ });
+
+ const articleSourceId = generateUuidV7();
+ await tx.insert(articleSourceTable).values({
+ id: articleSourceId,
+ accountId: author.account.id,
+ publishedYear: 2026,
+ slug: "actor-stats-article",
+ tags: [],
+ allowLlmTranslation: false,
+ published,
+ updated: published,
+ });
+
+ const articleId = generateUuidV7();
+ await tx.insert(postTable).values(
+ {
+ id: articleId,
+ iri: `http://localhost/objects/${articleId}`,
+ type: "Article",
+ visibility: "public",
+ actorId: author.actor.id,
+ articleSourceId,
+ contentHtml: "Article body
",
+ language: "en",
+ tags: {},
+ emojis: {},
+ url:
+ `http://localhost/@${author.account.username}/2026/actor-stats-article`,
+ published,
+ updated: published,
+ } satisfies NewPost,
+ );
+
+ const sharedId = generateUuidV7();
+ await tx.insert(postTable).values(
+ {
+ id: sharedId,
+ iri: `http://localhost/objects/${sharedId}`,
+ type: "Note",
+ visibility: "public",
+ actorId: author.actor.id,
+ sharedPostId: note.id,
+ contentHtml: "Shared note
",
+ language: "en",
+ tags: {},
+ emojis: {},
+ url:
+ `http://localhost/@${author.account.username}/shares/${sharedId}`,
+ published: new Date("2026-04-15T02:00:00.000Z"),
+ updated: new Date("2026-04-15T02:00:00.000Z"),
+ } satisfies NewPost,
+ );
+
+ const stats = await getActorStats(tx, author.actor.id);
+
+ assertEquals(stats, {
+ total: 4,
+ notes: 1,
+ notesWithReplies: 2,
+ shares: 1,
+ articles: 1,
+ });
+ });
+ },
+});
diff --git a/models/actor.ts b/models/actor.ts
index bc3d4ef4..e3d5f019 100644
--- a/models/actor.ts
+++ b/models/actor.ts
@@ -453,7 +453,7 @@ export async function getActorStats(
END
),
0
- )`,
+ )::integer`,
notesWithReplies: sql`
coalesce(
sum(
@@ -464,12 +464,12 @@ export async function getActorStats(
END
),
0
- )`,
+ )::integer`,
shares: sql`
coalesce(
sum(CASE WHEN ${postTable.sharedPostId} IS NULL THEN 0 ELSE 1 END),
0
- )
+ )::integer
`,
articles: sql`
coalesce(
@@ -481,7 +481,7 @@ export async function getActorStats(
END
),
0
- )
+ )::integer
`,
}).from(postTable).where(eq(postTable.actorId, actorId));
if (rows.length > 0) return rows[0];
diff --git a/models/apns.more.test.ts b/models/apns.more.test.ts
new file mode 100644
index 00000000..9a93ae5a
--- /dev/null
+++ b/models/apns.more.test.ts
@@ -0,0 +1,106 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ MAX_APNS_DEVICE_TOKENS_PER_ACCOUNT,
+ registerApnsDeviceToken,
+ unregisterApnsDeviceToken,
+} from "./apns.ts";
+import { insertAccountWithActor, withRollback } from "../test/postgres.ts";
+
+function tokenWithSuffix(suffix: string): string {
+ return `${"0".repeat(64 - suffix.length)}${suffix}`;
+}
+
+test("registerApnsDeviceToken() reassigns an existing token to the new account", async () => {
+ await withRollback(async (tx) => {
+ const first = await insertAccountWithActor(tx, {
+ username: "apnsfirst",
+ name: "APNS First",
+ email: "apnsfirst@example.com",
+ });
+ const second = await insertAccountWithActor(tx, {
+ username: "apnssecond",
+ name: "APNS Second",
+ email: "apnssecond@example.com",
+ });
+ const token = tokenWithSuffix("1");
+
+ await registerApnsDeviceToken(tx, first.account.id, token);
+ const reassigned = await registerApnsDeviceToken(
+ tx,
+ second.account.id,
+ token,
+ );
+
+ assert.ok(reassigned != null);
+ assert.equal(reassigned.accountId, second.account.id);
+
+ const stored = await tx.query.apnsDeviceTokenTable.findMany({
+ where: { deviceToken: token },
+ });
+ assert.equal(stored.length, 1);
+ assert.equal(stored[0].accountId, second.account.id);
+ });
+});
+
+test("registerApnsDeviceToken() evicts the oldest token when over the per-account limit", async () => {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "apnslimit",
+ name: "APNS Limit",
+ email: "apnslimit@example.com",
+ });
+
+ for (let i = 0; i < MAX_APNS_DEVICE_TOKENS_PER_ACCOUNT; i++) {
+ const suffix = (i + 1).toString(16).padStart(2, "0");
+ await registerApnsDeviceToken(
+ tx,
+ account.account.id,
+ tokenWithSuffix(suffix),
+ );
+ }
+
+ const extraToken = tokenWithSuffix("ff");
+ await registerApnsDeviceToken(tx, account.account.id, extraToken);
+
+ const tokens = await tx.query.apnsDeviceTokenTable.findMany({
+ where: { accountId: account.account.id },
+ orderBy: { created: "asc" },
+ });
+ assert.equal(tokens.length, MAX_APNS_DEVICE_TOKENS_PER_ACCOUNT);
+ assert.ok(tokens.some((row) => row.deviceToken === extraToken));
+ assert.ok(!tokens.some((row) => row.deviceToken === tokenWithSuffix("01")));
+ });
+});
+
+test("unregisterApnsDeviceToken() only removes tokens owned by the account", async () => {
+ await withRollback(async (tx) => {
+ const owner = await insertAccountWithActor(tx, {
+ username: "apnsowner",
+ name: "APNS Owner",
+ email: "apnsowner@example.com",
+ });
+ const other = await insertAccountWithActor(tx, {
+ username: "apnsother",
+ name: "APNS Other",
+ email: "apnsother@example.com",
+ });
+ const token = tokenWithSuffix("ab");
+
+ await registerApnsDeviceToken(tx, owner.account.id, token);
+
+ assert.equal(
+ await unregisterApnsDeviceToken(tx, other.account.id, token),
+ false,
+ );
+ assert.equal(
+ await unregisterApnsDeviceToken(tx, owner.account.id, token),
+ true,
+ );
+
+ const stored = await tx.query.apnsDeviceTokenTable.findMany({
+ where: { deviceToken: token },
+ });
+ assert.deepEqual(stored, []);
+ });
+});
diff --git a/models/article.background.test.ts b/models/article.background.test.ts
new file mode 100644
index 00000000..3044fca7
--- /dev/null
+++ b/models/article.background.test.ts
@@ -0,0 +1,136 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { articleContentTable, articleSourceTable } from "./schema.ts";
+import {
+ startArticleContentSummary,
+ startArticleContentTranslation,
+} from "./article.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ withRollback,
+} from "../test/postgres.ts";
+import { generateUuidV7 } from "./uuid.ts";
+
+async function waitFor(predicate: () => Promise, timeoutMs = 10000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (await predicate()) return;
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ throw new Error(
+ `Timed out waiting for async background state after ${timeoutMs}ms`,
+ );
+}
+
+test("startArticleContentSummary() resets summaryStarted when summarization fails", async () => {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "summarybackground",
+ name: "Summary Background",
+ email: "summarybackground@example.com",
+ });
+ const sourceId = generateUuidV7();
+ const published = new Date("2026-04-15T00:00:00.000Z");
+
+ await tx.insert(articleSourceTable).values({
+ id: sourceId,
+ accountId: author.account.id,
+ publishedYear: 2026,
+ slug: "summary-background",
+ tags: [],
+ allowLlmTranslation: false,
+ published,
+ updated: published,
+ });
+ await tx.insert(articleContentTable).values({
+ sourceId,
+ language: "en",
+ title: "Summary background",
+ content: "Body",
+ published,
+ updated: published,
+ });
+
+ const content = await tx.query.articleContentTable.findFirst({
+ where: { sourceId, language: "en" },
+ });
+ assert.ok(content != null);
+
+ await startArticleContentSummary(tx, {} as never, content);
+
+ const started = await tx.query.articleContentTable.findFirst({
+ where: { sourceId, language: "en" },
+ });
+ assert.ok(started?.summaryStarted != null);
+
+ await waitFor(async () => {
+ const current = await tx.query.articleContentTable.findFirst({
+ where: { sourceId, language: "en" },
+ });
+ return current?.summaryStarted == null;
+ });
+ });
+});
+
+test("startArticleContentTranslation() deletes queued rows when translation fails", async () => {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ fedCtx.data.models = {
+ summarizer: {} as never,
+ translator: {} as never,
+ } as typeof fedCtx.data.models;
+ const author = await insertAccountWithActor(tx, {
+ username: "translationbackground",
+ name: "Translation Background",
+ email: "translationbackground@example.com",
+ });
+ const requester = await insertAccountWithActor(tx, {
+ username: "translationrequester",
+ name: "Translation Requester",
+ email: "translationrequester@example.com",
+ });
+ const sourceId = generateUuidV7();
+ const published = new Date("2026-04-15T00:00:00.000Z");
+
+ await tx.insert(articleSourceTable).values({
+ id: sourceId,
+ accountId: author.account.id,
+ publishedYear: 2026,
+ slug: "translation-background",
+ tags: ["relay"],
+ allowLlmTranslation: true,
+ published,
+ updated: published,
+ });
+ await tx.insert(articleContentTable).values({
+ sourceId,
+ language: "en",
+ title: "Translation background",
+ content: "Original body",
+ published,
+ updated: published,
+ });
+
+ const content = await tx.query.articleContentTable.findFirst({
+ where: { sourceId, language: "en" },
+ });
+ assert.ok(content != null);
+
+ const queued = await startArticleContentTranslation(fedCtx, {
+ content,
+ targetLanguage: "ko",
+ requester: requester.account,
+ });
+
+ assert.equal(queued.language, "ko");
+ assert.equal(queued.beingTranslated, true);
+
+ await waitFor(async () => {
+ const current = await tx.query.articleContentTable.findFirst({
+ where: { sourceId, language: "ko" },
+ });
+ return current == null;
+ });
+ });
+});
diff --git a/models/article.draft.test.ts b/models/article.draft.test.ts
new file mode 100644
index 00000000..58568cfa
--- /dev/null
+++ b/models/article.draft.test.ts
@@ -0,0 +1,79 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { deleteArticleDraft, updateArticleDraft } from "./article.ts";
+import { generateUuidV7 } from "./uuid.ts";
+import { insertAccountWithActor, withRollback } from "../test/postgres.ts";
+
+test("updateArticleDraft() creates and updates drafts with normalized tags", async () => {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "articledraftowner",
+ name: "Article Draft Owner",
+ email: "articledraftowner@example.com",
+ });
+ const draftId = generateUuidV7();
+
+ const created = await updateArticleDraft(tx, {
+ id: draftId,
+ accountId: account.account.id,
+ title: "Draft title",
+ content: "Draft content",
+ tags: [" #fediverse ", "#fediverse", "solid", "", "bad,tag"],
+ });
+
+ assert.equal(created.id, draftId);
+ assert.deepEqual(created.tags, ["fediverse", "solid"]);
+
+ const updated = await updateArticleDraft(tx, {
+ id: draftId,
+ accountId: account.account.id,
+ title: "Updated title",
+ content: "Updated content",
+ tags: [" #relay", "relay", "graphql "],
+ });
+
+ assert.equal(updated.id, draftId);
+ assert.equal(updated.title, "Updated title");
+ assert.equal(updated.content, "Updated content");
+ assert.deepEqual(updated.tags, ["relay", "graphql"]);
+ assert.equal(updated.updated.getTime() >= created.updated.getTime(), true);
+ });
+});
+
+test("deleteArticleDraft() only deletes drafts owned by the account", async () => {
+ await withRollback(async (tx) => {
+ const owner = await insertAccountWithActor(tx, {
+ username: "draftdeleteowner",
+ name: "Draft Delete Owner",
+ email: "draftdeleteowner@example.com",
+ });
+ const other = await insertAccountWithActor(tx, {
+ username: "draftdeleteother",
+ name: "Draft Delete Other",
+ email: "draftdeleteother@example.com",
+ });
+ const draft = await updateArticleDraft(tx, {
+ id: generateUuidV7(),
+ accountId: owner.account.id,
+ title: "Owned draft",
+ content: "Owned content",
+ tags: [],
+ });
+
+ const wrongAccountDelete = await deleteArticleDraft(
+ tx,
+ other.account.id,
+ draft.id,
+ );
+ assert.equal(wrongAccountDelete, undefined);
+
+ const deleted = await deleteArticleDraft(tx, owner.account.id, draft.id);
+ assert.ok(deleted != null);
+ assert.equal(deleted.id, draft.id);
+
+ const stored = await tx.query.articleDraftTable.findFirst({
+ where: { id: draft.id },
+ });
+ assert.equal(stored, undefined);
+ });
+});
diff --git a/models/article.lifecycle.test.ts b/models/article.lifecycle.test.ts
new file mode 100644
index 00000000..77e447d1
--- /dev/null
+++ b/models/article.lifecycle.test.ts
@@ -0,0 +1,102 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { createArticle, updateArticle } from "./article.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ withRollback,
+} from "../test/postgres.ts";
+
+const fakeModels = {
+ summarizer: {} as never,
+ translator: {} as never,
+};
+
+test("createArticle() creates a post and timeline entry for the author", async () => {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ fedCtx.data.models = fakeModels as typeof fedCtx.data.models;
+ const author = await insertAccountWithActor(tx, {
+ username: "createarticleauthor",
+ name: "Create Article Author",
+ email: "createarticleauthor@example.com",
+ });
+ const published = new Date("2026-04-15T00:00:00.000Z");
+
+ const article = await createArticle(fedCtx, {
+ accountId: author.account.id,
+ publishedYear: 2026,
+ slug: "create-article",
+ tags: ["solid"],
+ allowLlmTranslation: false,
+ published,
+ updated: published,
+ title: "Article title",
+ content: "Hello **article**",
+ language: "en",
+ });
+
+ assert.ok(article != null);
+ assert.equal(article.actor.id, author.actor.id);
+ assert.equal(article.articleSource.slug, "create-article");
+ assert.equal(article.name, "Article title");
+ assert.match(article.contentHtml, /article<\/strong>/);
+
+ const timelineItem = await tx.query.timelineItemTable.findFirst({
+ where: {
+ accountId: author.account.id,
+ postId: article.id,
+ },
+ });
+ assert.ok(timelineItem != null);
+ assert.equal(timelineItem.originalAuthorId, author.actor.id);
+ assert.equal(timelineItem.lastSharerId, null);
+ });
+});
+
+test("updateArticle() rewrites the persisted article post", async () => {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ fedCtx.data.models = fakeModels as typeof fedCtx.data.models;
+ const author = await insertAccountWithActor(tx, {
+ username: "updatearticleauthor",
+ name: "Update Article Author",
+ email: "updatearticleauthor@example.com",
+ });
+ const article = await createArticle(fedCtx, {
+ accountId: author.account.id,
+ publishedYear: 2026,
+ slug: "original-article",
+ tags: [],
+ allowLlmTranslation: false,
+ published: new Date("2026-04-15T00:00:00.000Z"),
+ updated: new Date("2026-04-15T00:00:00.000Z"),
+ title: "Original article",
+ content: "Original body",
+ language: "en",
+ });
+ assert.ok(article != null);
+
+ const updated = await updateArticle(fedCtx, article.articleSource.id, {
+ slug: "updated-article",
+ title: "Updated article",
+ content: "Updated **body**",
+ });
+
+ assert.ok(updated != null);
+ assert.equal(updated.id, article.id);
+ assert.equal(updated.articleSource.id, article.articleSource.id);
+ assert.equal(updated.articleSource.slug, "updated-article");
+ assert.equal(updated.name, "Updated article");
+ assert.match(updated.contentHtml, /body<\/strong>/);
+ assert.match(updated.url ?? "", /updated-article$/);
+
+ const storedPost = await tx.query.postTable.findFirst({
+ where: { id: article.id },
+ });
+ assert.ok(storedPost != null);
+ assert.equal(storedPost.articleSourceId, article.articleSource.id);
+ assert.equal(storedPost.name, "Updated article");
+ assert.match(storedPost.contentHtml, /body<\/strong>/);
+ });
+});
diff --git a/models/article.source.test.ts b/models/article.source.test.ts
new file mode 100644
index 00000000..70c883e4
--- /dev/null
+++ b/models/article.source.test.ts
@@ -0,0 +1,258 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ createArticleSource,
+ getArticleSource,
+ getOriginalArticleContent,
+ updateArticleSource,
+} from "./article.ts";
+import { updateAccountData } from "./account.ts";
+import {
+ articleContentTable,
+ articleSourceTable,
+ type NewPost,
+ postTable,
+} from "./schema.ts";
+import { generateUuidV7 } from "./uuid.ts";
+import { insertAccountWithActor, withRollback } from "../test/postgres.ts";
+
+const fakeModels = {
+ summarizer: {} as never,
+ translator: {} as never,
+};
+
+test("createArticleSource() creates a source and initial content", async () => {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "articlesourceowner",
+ name: "Article Source Owner",
+ email: "articlesourceowner@example.com",
+ });
+ const published = new Date("2026-04-15T00:00:00.000Z");
+
+ const source = await createArticleSource(tx, fakeModels, {
+ accountId: author.account.id,
+ publishedYear: 2026,
+ slug: "source-test",
+ tags: ["relay"],
+ allowLlmTranslation: false,
+ published,
+ updated: published,
+ title: "Original title",
+ content: "Original content",
+ language: "en",
+ });
+
+ assert.ok(source != null);
+ assert.equal(source.accountId, author.account.id);
+ assert.equal(source.slug, "source-test");
+ assert.equal(source.contents.length, 1);
+ assert.equal(source.contents[0].language, "en");
+ assert.equal(source.contents[0].title, "Original title");
+ assert.equal(source.contents[0].content, "Original content");
+
+ const storedContents = await tx.query.articleContentTable.findMany({
+ where: { sourceId: source.id },
+ orderBy: { published: "asc" },
+ });
+ assert.equal(storedContents.length, 1);
+ assert.equal(storedContents[0].title, "Original title");
+ });
+});
+
+test("getOriginalArticleContent() picks the earliest non-translation content", () => {
+ const sourceId = generateUuidV7();
+ const original = {
+ sourceId,
+ language: "en",
+ title: "Original",
+ summary: null,
+ summaryStarted: null,
+ content: "Original body",
+ ogImageKey: null,
+ originalLanguage: null,
+ translatorId: null,
+ translationRequesterId: null,
+ beingTranslated: false,
+ updated: new Date("2026-04-15T00:00:00.000Z"),
+ published: new Date("2026-04-15T00:00:00.000Z"),
+ };
+ const newerOriginal = {
+ ...original,
+ language: "fr",
+ title: "Second original",
+ published: new Date("2026-04-15T01:00:00.000Z"),
+ };
+ const translation = {
+ ...original,
+ language: "ko",
+ title: "Translated",
+ originalLanguage: "en",
+ translationRequesterId: generateUuidV7(),
+ };
+
+ const selected = getOriginalArticleContent({
+ id: sourceId,
+ accountId: generateUuidV7(),
+ publishedYear: 2026,
+ slug: "original-content",
+ tags: [],
+ allowLlmTranslation: false,
+ updated: new Date("2026-04-15T00:00:00.000Z"),
+ published: new Date("2026-04-15T00:00:00.000Z"),
+ contents: [translation, newerOriginal, original],
+ });
+
+ assert.deepEqual(selected, original);
+});
+
+test("updateArticleSource() updates the original content and preserves translations", async () => {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "updatearticlesource",
+ name: "Update Article Source",
+ email: "updatearticlesource@example.com",
+ });
+ const sourceId = generateUuidV7();
+ const published = new Date("2026-04-15T00:00:00.000Z");
+
+ await tx.insert(articleSourceTable).values({
+ id: sourceId,
+ accountId: author.account.id,
+ publishedYear: 2026,
+ slug: "update-source",
+ tags: ["solid"],
+ allowLlmTranslation: false,
+ published,
+ updated: published,
+ });
+ await tx.insert(articleContentTable).values([
+ {
+ sourceId,
+ language: "en",
+ title: "Original title",
+ content: "Original content",
+ published,
+ updated: published,
+ },
+ {
+ sourceId,
+ language: "ko",
+ title: "Translated title",
+ content: "Translated content",
+ originalLanguage: "en",
+ translationRequesterId: author.account.id,
+ beingTranslated: false,
+ published: new Date("2026-04-15T01:00:00.000Z"),
+ updated: new Date("2026-04-15T01:00:00.000Z"),
+ },
+ ]);
+
+ const updated = await updateArticleSource(tx, sourceId, {
+ title: "Updated title",
+ content: "Updated content",
+ slug: "updated-source",
+ });
+
+ assert.ok(updated != null);
+ assert.equal(updated.slug, "updated-source");
+ assert.equal(updated.contents.length, 2);
+
+ const originalContent = updated.contents.find((content) =>
+ content.originalLanguage == null
+ );
+ const translatedContent = updated.contents.find((content) =>
+ content.originalLanguage === "en"
+ );
+
+ assert.ok(originalContent != null);
+ assert.equal(originalContent.title, "Updated title");
+ assert.equal(originalContent.content, "Updated content");
+ assert.ok(translatedContent != null);
+ assert.equal(translatedContent.title, "Translated title");
+ assert.equal(translatedContent.content, "Translated content");
+ });
+});
+
+test("getArticleSource() resolves renamed usernames and returns ordered contents", async () => {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "oldarticleuser",
+ name: "Old Article User",
+ email: "oldarticleuser@example.com",
+ });
+ const sourceId = generateUuidV7();
+ const published = new Date("2026-04-15T00:00:00.000Z");
+
+ await tx.insert(articleSourceTable).values({
+ id: sourceId,
+ accountId: author.account.id,
+ publishedYear: 2026,
+ slug: "ordered-article",
+ tags: [],
+ allowLlmTranslation: false,
+ published,
+ updated: published,
+ });
+ await tx.insert(articleContentTable).values([
+ {
+ sourceId,
+ language: "ko",
+ title: "Second title",
+ content: "Second body",
+ published: new Date("2026-04-15T01:00:00.000Z"),
+ updated: new Date("2026-04-15T01:00:00.000Z"),
+ },
+ {
+ sourceId,
+ language: "en",
+ title: "First title",
+ content: "First body",
+ published,
+ updated: published,
+ },
+ ]);
+ await tx.insert(postTable).values(
+ {
+ id: generateUuidV7(),
+ iri: `http://localhost/objects/${sourceId}`,
+ type: "Article",
+ visibility: "public",
+ actorId: author.actor.id,
+ articleSourceId: sourceId,
+ name: "First title",
+ contentHtml: "First body
",
+ language: "en",
+ tags: {},
+ emojis: {},
+ url:
+ `http://localhost/@${author.account.username}/2026/ordered-article`,
+ published,
+ updated: published,
+ } satisfies NewPost,
+ );
+
+ const renamed = await updateAccountData(tx, {
+ id: author.account.id,
+ username: "newarticleuser",
+ });
+ assert.ok(renamed != null);
+
+ const source = await getArticleSource(
+ tx,
+ "oldarticleuser",
+ 2026,
+ "ordered-article",
+ undefined,
+ );
+
+ assert.ok(source != null);
+ assert.equal(source.account.username, "newarticleuser");
+ assert.deepEqual(
+ source.contents.map((content) => content.language),
+ ["en", "ko"],
+ );
+ assert.equal(source.post.actor.id, author.actor.id);
+ assert.equal(source.post.articleSourceId, sourceId);
+ });
+});
diff --git a/models/blocking.more.test.ts b/models/blocking.more.test.ts
new file mode 100644
index 00000000..e736465a
--- /dev/null
+++ b/models/blocking.more.test.ts
@@ -0,0 +1,82 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import * as vocab from "@fedify/vocab";
+import { block, persistBlocking, unblock } from "./blocking.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ insertRemoteActor,
+ withRollback,
+} from "../test/postgres.ts";
+
+test("block() and unblock() send federation activities for remote actors", async () => {
+ await withRollback(async (tx) => {
+ const sent: unknown[] = [];
+ const baseFedCtx = createFedCtx(tx);
+ const fedCtx = {
+ ...baseFedCtx,
+ sendActivity(...args: unknown[]) {
+ sent.push(args);
+ return Promise.resolve(undefined);
+ },
+ } as typeof baseFedCtx;
+ const blocker = await insertAccountWithActor(tx, {
+ username: "blockremoteowner",
+ name: "Block Remote Owner",
+ email: "blockremoteowner@example.com",
+ });
+ const remote = await insertRemoteActor(tx, {
+ username: "blockremoteactor",
+ name: "Block Remote Actor",
+ host: "remote.example",
+ });
+
+ const created = await block(fedCtx, blocker.account, remote);
+
+ assert.ok(created != null);
+ assert.equal(sent.length, 1);
+
+ const removed = await unblock(fedCtx, blocker.account, remote);
+
+ assert.ok(removed != null);
+ assert.equal(sent.length, 2);
+ const stored = await tx.query.blockingTable.findFirst({
+ where: { blockerId: blocker.actor.id, blockeeId: remote.id },
+ });
+ assert.equal(stored, undefined);
+ });
+});
+
+test("persistBlocking() stores a remote block activity between remote actors", async () => {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const blocker = await insertRemoteActor(tx, {
+ username: "persistblocker",
+ name: "Persist Blocker",
+ host: "blocker.example",
+ iri: "https://blocker.example/users/blocker",
+ });
+ const blockee = await insertRemoteActor(tx, {
+ username: "persistblockee",
+ name: "Persist Blockee",
+ host: "blockee.example",
+ iri: "https://blockee.example/users/blockee",
+ });
+
+ const activity = new vocab.Block({
+ id: new URL("https://blocker.example/activities/block-1"),
+ actor: new URL(blocker.iri),
+ object: new URL(blockee.iri),
+ });
+
+ await persistBlocking(fedCtx, activity);
+
+ const duplicate = await persistBlocking(fedCtx, activity);
+ assert.equal(duplicate, undefined);
+
+ const rows = await tx.query.blockingTable.findMany({
+ where: { blockerId: blocker.id, blockeeId: blockee.id },
+ });
+ assert.equal(rows.length, 1);
+ });
+});
diff --git a/models/blocking.test.ts b/models/blocking.test.ts
new file mode 100644
index 00000000..9a6e4237
--- /dev/null
+++ b/models/blocking.test.ts
@@ -0,0 +1,151 @@
+import { assert } from "@std/assert/assert";
+import { assertEquals } from "@std/assert/equals";
+import { and, eq } from "drizzle-orm";
+import { block, unblock } from "./blocking.ts";
+import { follow } from "./following.ts";
+import { blockingTable, followingTable } from "./schema.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ insertRemoteActor,
+ withRollback,
+} from "../test/postgres.ts";
+
+Deno.test({
+ name: "block() removes local follow relationships in both directions",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const blocker = await insertAccountWithActor(tx, {
+ username: "blocker",
+ name: "Blocker",
+ email: "blocker@example.com",
+ });
+ const blockee = await insertAccountWithActor(tx, {
+ username: "blockee",
+ name: "Blockee",
+ email: "blockee@example.com",
+ });
+
+ await follow(fedCtx, blocker.account, blockee.actor);
+ await follow(fedCtx, blockee.account, blocker.actor);
+
+ const created = await block(fedCtx, blocker.account, blockee.actor);
+
+ assert(created != null);
+
+ const blocking = await tx.query.blockingTable.findFirst({
+ where: {
+ blockerId: blocker.actor.id,
+ blockeeId: blockee.actor.id,
+ },
+ });
+ assert(blocking != null);
+
+ const followRows = await tx.select().from(followingTable).where(
+ and(
+ eq(followingTable.followerId, blocker.actor.id),
+ eq(followingTable.followeeId, blockee.actor.id),
+ ),
+ );
+ assertEquals(followRows, []);
+
+ const reverseFollowRows = await tx.select().from(followingTable).where(
+ and(
+ eq(followingTable.followerId, blockee.actor.id),
+ eq(followingTable.followeeId, blocker.actor.id),
+ ),
+ );
+ assertEquals(reverseFollowRows, []);
+ });
+ },
+});
+
+Deno.test({
+ name: "unblock() deletes the blocking row",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const blocker = await insertAccountWithActor(tx, {
+ username: "unblocker",
+ name: "Unblocker",
+ email: "unblocker@example.com",
+ });
+ const blockee = await insertAccountWithActor(tx, {
+ username: "unblockee",
+ name: "Unblockee",
+ email: "unblockee@example.com",
+ });
+
+ await block(fedCtx, blocker.account, blockee.actor);
+
+ const removed = await unblock(fedCtx, blocker.account, blockee.actor);
+
+ assert(removed != null);
+
+ const remaining = await tx.select().from(blockingTable).where(
+ and(
+ eq(blockingTable.blockerId, blocker.actor.id),
+ eq(blockingTable.blockeeId, blockee.actor.id),
+ ),
+ );
+ assertEquals(remaining, []);
+ });
+ },
+});
+
+Deno.test({
+ name:
+ "block() removes follow relationships with remote blockees in both directions",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const blocker = await insertAccountWithActor(tx, {
+ username: "remoteblocker",
+ name: "Remote Blocker",
+ email: "remoteblocker@example.com",
+ });
+ const remoteBlockee = await insertRemoteActor(tx, {
+ username: `remote-blockee-${
+ crypto.randomUUID().replaceAll("-", "").slice(0, 8)
+ }`,
+ name: "Remote Blockee",
+ host: "remote.example",
+ });
+
+ await follow(fedCtx, blocker.account, remoteBlockee);
+ await tx.insert(followingTable).values({
+ iri: `https://remote.example/follows/${crypto.randomUUID()}`,
+ followerId: remoteBlockee.id,
+ followeeId: blocker.actor.id,
+ accepted: new Date("2026-04-15T00:00:00.000Z"),
+ });
+
+ const created = await block(fedCtx, blocker.account, remoteBlockee);
+
+ assert(created != null);
+
+ const forwardFollow = await tx.select().from(followingTable).where(
+ and(
+ eq(followingTable.followerId, blocker.actor.id),
+ eq(followingTable.followeeId, remoteBlockee.id),
+ ),
+ );
+ assertEquals(forwardFollow, []);
+
+ const reverseFollow = await tx.select().from(followingTable).where(
+ and(
+ eq(followingTable.followerId, remoteBlockee.id),
+ eq(followingTable.followeeId, blocker.actor.id),
+ ),
+ );
+ assertEquals(reverseFollow, []);
+ });
+ },
+});
diff --git a/models/blocking.ts b/models/blocking.ts
index d01efe27..73fb75e0 100644
--- a/models/blocking.ts
+++ b/models/blocking.ts
@@ -71,6 +71,10 @@ export async function block(
): Promise {
const id = generateUuidV7();
const { db } = fedCtx.data;
+ const removeLocalFollowRelationships = async () => {
+ await removeFollower(fedCtx, blocker, blockee);
+ await unfollow(fedCtx, blocker, blockee);
+ };
const rows = await db.insert(blockingTable)
.values({
id,
@@ -83,6 +87,7 @@ export async function block(
})
.onConflictDoNothing()
.returning();
+ await removeLocalFollowRelationships();
if (rows.length < 1) {
return await db.query.blockingTable.findFirst({
where: {
diff --git a/models/following.more.test.ts b/models/following.more.test.ts
new file mode 100644
index 00000000..90401b45
--- /dev/null
+++ b/models/following.more.test.ts
@@ -0,0 +1,122 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ createFollowingIri,
+ follow,
+ removeFollower,
+ unfollow,
+} from "./following.ts";
+import { followingTable } from "./schema.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ insertRemoteActor,
+ withRollback,
+} from "../test/postgres.ts";
+
+test("createFollowingIri() builds a local follow IRI under the actor URI", async () => {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const follower = await insertAccountWithActor(tx, {
+ username: "followiriowner",
+ name: "Follow IRI Owner",
+ email: "followiriowner@example.com",
+ });
+
+ const iri = createFollowingIri(fedCtx, follower.account);
+
+ assert.equal(iri.origin, "http://localhost");
+ assert.match(
+ iri.href,
+ new RegExp(`/actors/${follower.account.id}#follow/`),
+ );
+ });
+});
+
+test("follow() and unfollow() send federation activities for remote actors", async () => {
+ await withRollback(async (tx) => {
+ const sent: unknown[] = [];
+ const baseFedCtx = createFedCtx(tx);
+ const fedCtx = {
+ ...baseFedCtx,
+ sendActivity(...args: unknown[]) {
+ sent.push(args);
+ return Promise.resolve(undefined);
+ },
+ } as typeof baseFedCtx;
+ const local = await insertAccountWithActor(tx, {
+ username: "followremoteowner",
+ name: "Follow Remote Owner",
+ email: "followremoteowner@example.com",
+ });
+ const remote = await insertRemoteActor(tx, {
+ username: "followremoteactor",
+ name: "Follow Remote Actor",
+ host: "remote.example",
+ });
+
+ const following = await follow(fedCtx, local.account, remote);
+
+ assert.ok(following != null);
+ assert.equal(following.accepted, null);
+ assert.equal(sent.length, 1);
+
+ const removed = await unfollow(fedCtx, local.account, remote);
+
+ assert.ok(removed != null);
+ assert.equal(sent.length, 2);
+ const stored = await tx.query.followingTable.findFirst({
+ where: {
+ followerId: local.actor.id,
+ followeeId: remote.id,
+ },
+ });
+ assert.equal(stored, undefined);
+ });
+});
+
+test("removeFollower() sends a Reject activity for remote followers", async () => {
+ await withRollback(async (tx) => {
+ const sent: unknown[] = [];
+ const baseFedCtx = createFedCtx(tx);
+ const fedCtx = {
+ ...baseFedCtx,
+ sendActivity(...args: unknown[]) {
+ sent.push(args);
+ return Promise.resolve(undefined);
+ },
+ } as typeof baseFedCtx;
+ const followee = await insertAccountWithActor(tx, {
+ username: "removefollowerowner",
+ name: "Remove Follower Owner",
+ email: "removefollowerowner@example.com",
+ });
+ const remoteFollower = await insertRemoteActor(tx, {
+ username: "remotefollower",
+ name: "Remote Follower",
+ host: "remote.example",
+ });
+ await tx.insert(followingTable).values({
+ iri: `https://remote.example/follows/${remoteFollower.id}`,
+ followerId: remoteFollower.id,
+ followeeId: followee.actor.id,
+ accepted: new Date("2026-04-15T00:00:00.000Z"),
+ });
+
+ const removed = await removeFollower(
+ fedCtx,
+ followee.account,
+ remoteFollower,
+ );
+
+ assert.ok(removed != null);
+ assert.equal(sent.length, 1);
+ const remaining = await tx.query.followingTable.findFirst({
+ where: {
+ followerId: remoteFollower.id,
+ followeeId: followee.actor.id,
+ },
+ });
+ assert.equal(remaining, undefined);
+ });
+});
diff --git a/models/following.test.ts b/models/following.test.ts
new file mode 100644
index 00000000..9953d054
--- /dev/null
+++ b/models/following.test.ts
@@ -0,0 +1,194 @@
+import { assert } from "@std/assert/assert";
+import { assertEquals } from "@std/assert/equals";
+import { eq } from "drizzle-orm";
+import { acceptFollowing, follow, unfollow } from "./following.ts";
+import { actorTable, followingTable, notificationTable } from "./schema.ts";
+import { generateUuidV7 } from "./uuid.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ seedLocalInstance,
+ withRollback,
+} from "../test/postgres.ts";
+
+Deno.test({
+ name: "follow() auto-accepts local follows and creates a notification",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const suffix = crypto.randomUUID().replaceAll("-", "").slice(0, 8);
+ const follower = await insertAccountWithActor(tx, {
+ username: `follower${suffix}`,
+ name: "Follower",
+ email: `follower-${suffix}@example.com`,
+ });
+ const followee = await insertAccountWithActor(tx, {
+ username: `followee${suffix}`,
+ name: "Followee",
+ email: `followee-${suffix}@example.com`,
+ });
+
+ const created = await follow(fedCtx, follower.account, followee.actor);
+
+ assert(created != null);
+ assert(created.accepted != null);
+
+ const stored = await tx.query.followingTable.findFirst({
+ where: {
+ followerId: follower.actor.id,
+ followeeId: followee.actor.id,
+ },
+ });
+ assert(stored != null);
+ assert(stored.accepted != null);
+
+ const followerActor = await tx.query.actorTable.findFirst({
+ where: { id: follower.actor.id },
+ });
+ const followeeActor = await tx.query.actorTable.findFirst({
+ where: { id: followee.actor.id },
+ });
+ assert(followerActor != null);
+ assert(followeeActor != null);
+ assertEquals(followerActor.followeesCount, 1);
+ assertEquals(followeeActor.followersCount, 1);
+
+ const notification = await tx.query.notificationTable.findFirst({
+ where: {
+ accountId: followee.account.id,
+ type: "follow",
+ },
+ });
+ assert(notification != null);
+ assertEquals(notification.actorIds, [follower.actor.id]);
+ });
+ },
+});
+
+Deno.test({
+ name: "acceptFollowing() updates counts for pending remote follows",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const suffix = crypto.randomUUID().replaceAll("-", "").slice(0, 8);
+ const follower = await insertAccountWithActor(tx, {
+ username: `pendingfollower${suffix}`,
+ name: "Pending Follower",
+ email: `pendingfollower-${suffix}@example.com`,
+ });
+
+ await seedLocalInstance(tx, "remote.example");
+ const remoteActorId = generateUuidV7();
+ await tx.insert(actorTable).values({
+ id: remoteActorId,
+ iri: "https://remote.example/users/remote",
+ type: "Person",
+ username: `remote${suffix}`,
+ instanceHost: "remote.example",
+ handleHost: "remote.example",
+ name: "Remote",
+ inboxUrl: "https://remote.example/users/remote/inbox",
+ sharedInboxUrl: "https://remote.example/inbox",
+ });
+ const remoteActor = await tx.query.actorTable.findFirst({
+ where: { id: remoteActorId },
+ });
+ assert(remoteActor != null);
+
+ const pending = await follow(fedCtx, follower.account, remoteActor);
+
+ assert(pending != null);
+ assertEquals(pending.accepted, null);
+
+ const followerBefore = await tx.query.actorTable.findFirst({
+ where: { id: follower.actor.id },
+ });
+ const remoteBefore = await tx.query.actorTable.findFirst({
+ where: { id: remoteActor.id },
+ });
+ assert(followerBefore != null);
+ assert(remoteBefore != null);
+ assertEquals(followerBefore.followeesCount, 0);
+ assertEquals(remoteBefore.followersCount, 0);
+
+ const accepted = await acceptFollowing(tx, follower.account, remoteActor);
+
+ assert(accepted != null);
+ assert(accepted.accepted != null);
+
+ const followerAfter = await tx.query.actorTable.findFirst({
+ where: { id: follower.actor.id },
+ });
+ const remoteAfter = await tx.query.actorTable.findFirst({
+ where: { id: remoteActor.id },
+ });
+ assert(followerAfter != null);
+ assert(remoteAfter != null);
+ assertEquals(followerAfter.followeesCount, 1);
+ assertEquals(remoteAfter.followersCount, 1);
+ });
+ },
+});
+
+Deno.test({
+ name: "unfollow() removes local follow state and notification",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const suffix = crypto.randomUUID().replaceAll("-", "").slice(0, 8);
+ const follower = await insertAccountWithActor(tx, {
+ username: `leaver${suffix}`,
+ name: "Leaver",
+ email: `leaver-${suffix}@example.com`,
+ });
+ const followee = await insertAccountWithActor(tx, {
+ username: `target${suffix}`,
+ name: "Target",
+ email: `target-${suffix}@example.com`,
+ });
+
+ await follow(fedCtx, follower.account, followee.actor);
+
+ const removed = await unfollow(fedCtx, follower.account, followee.actor);
+
+ assert(removed != null);
+
+ const stored = await tx.query.followingTable.findFirst({
+ where: {
+ followerId: follower.actor.id,
+ followeeId: followee.actor.id,
+ },
+ });
+ assertEquals(stored, undefined);
+
+ const followerActor = await tx.query.actorTable.findFirst({
+ where: { id: follower.actor.id },
+ });
+ const followeeActor = await tx.query.actorTable.findFirst({
+ where: { id: followee.actor.id },
+ });
+ assert(followerActor != null);
+ assert(followeeActor != null);
+ assertEquals(followerActor.followeesCount, 0);
+ assertEquals(followeeActor.followersCount, 0);
+
+ const notifications = await tx.select().from(notificationTable).where(eq(
+ notificationTable.accountId,
+ followee.account.id,
+ ));
+ assertEquals(notifications, []);
+
+ const followings = await tx.select().from(followingTable).where(eq(
+ followingTable.followeeId,
+ followee.actor.id,
+ ));
+ assertEquals(followings, []);
+ });
+ },
+});
diff --git a/models/markup.test.ts b/models/markup.test.ts
new file mode 100644
index 00000000..8aa97c75
--- /dev/null
+++ b/models/markup.test.ts
@@ -0,0 +1,57 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { extractMentionsFromHtml, renderMarkup } from "./markup.ts";
+import {
+ createFedCtx,
+ createTestKv,
+ insertRemoteActor,
+ withRollback,
+} from "../test/postgres.ts";
+
+test("renderMarkup() renders title, toc, hashtags, and caches results", async () => {
+ const { kv, store } = createTestKv();
+ const markup = `# Hello World
+
+## Section Title
+
+Welcome to #HackersPub.`;
+
+ const first = await renderMarkup(null, markup, {
+ kv: kv as never,
+ docId: "doc-1",
+ });
+
+ assert.equal(first.title, "Hello World");
+ assert.deepEqual(first.hashtags, ["#HackersPub"]);
+ assert.match(first.html, / {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const actor = await insertRemoteActor(tx, {
+ username: "mentionee",
+ name: "Mentionee",
+ host: "remote.example",
+ iri: "https://remote.example/users/mentionee",
+ });
+
+ const mentions = await extractMentionsFromHtml(
+ fedCtx,
+ `
@mentionee
`,
+ );
+
+ assert.equal(mentions.length, 1);
+ assert.equal(mentions[0].actor.id, actor.id);
+ });
+});
diff --git a/models/medium.test.ts b/models/medium.test.ts
new file mode 100644
index 00000000..b892f9cd
--- /dev/null
+++ b/models/medium.test.ts
@@ -0,0 +1,80 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import * as vocab from "@fedify/vocab";
+import { persistPostMedium } from "./medium.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ insertNotePost,
+ withMockFetch,
+ withRollback,
+} from "../test/postgres.ts";
+
+test("persistPostMedium() stores image attachments and infers media type from content-type", async () => {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const account = await insertAccountWithActor(tx, {
+ username: "mediapostowner",
+ name: "Media Post Owner",
+ email: "mediapostowner@example.com",
+ });
+ const { post } = await insertNotePost(tx, {
+ account: account.account,
+ content: "Post with media",
+ });
+
+ await withMockFetch(async () => {
+ return new Response(new Uint8Array([1, 2, 3]), {
+ status: 200,
+ headers: { "Content-Type": "image/png" },
+ });
+ }, async () => {
+ const medium = await persistPostMedium(
+ fedCtx,
+ new vocab.Image({
+ url: new URL("https://remote.example/media/no-extension"),
+ name: "Alt text",
+ width: 640,
+ height: 480,
+ }),
+ post.id,
+ 0,
+ );
+
+ assert.ok(medium != null);
+ assert.equal(medium.postId, post.id);
+ assert.equal(medium.index, 0);
+ assert.equal(medium.type, "image/png");
+ assert.equal(medium.alt, "Alt text");
+ assert.equal(medium.width, 640);
+ assert.equal(medium.height, 480);
+ });
+ });
+});
+
+test("persistPostMedium() ignores unsupported non-image documents", async () => {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const account = await insertAccountWithActor(tx, {
+ username: "unsupportedmediaowner",
+ name: "Unsupported Media Owner",
+ email: "unsupportedmediaowner@example.com",
+ });
+ const { post } = await insertNotePost(tx, {
+ account: account.account,
+ content: "Unsupported media post",
+ });
+
+ const medium = await persistPostMedium(
+ fedCtx,
+ new vocab.Document({
+ url: new URL("https://remote.example/archive.zip"),
+ mediaType: "application/zip",
+ }),
+ post.id,
+ 0,
+ );
+
+ assert.equal(medium, undefined);
+ });
+});
diff --git a/models/note.lifecycle.test.ts b/models/note.lifecycle.test.ts
new file mode 100644
index 00000000..edf3ea03
--- /dev/null
+++ b/models/note.lifecycle.test.ts
@@ -0,0 +1,99 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import type { Context } from "@fedify/fedify";
+import type { ContextData } from "./context.ts";
+import type { Transaction } from "./db.ts";
+import { createNote, updateNote } from "./note.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ withRollback,
+} from "../test/postgres.ts";
+
+test("createNote() creates a post and timeline entry for the author", async () => {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const author = await insertAccountWithActor(tx, {
+ username: "createnoteauthor",
+ name: "Create Note Author",
+ email: "createnoteauthor@example.com",
+ });
+ const published = new Date("2026-04-15T00:00:00.000Z");
+
+ const note = await createNote(
+ fedCtx as unknown as Context>,
+ {
+ accountId: author.account.id,
+ visibility: "public",
+ content: "Hello **world**",
+ language: "en",
+ media: [],
+ published,
+ updated: published,
+ },
+ );
+
+ assert.ok(note != null);
+ assert.equal(note.noteSource.accountId, author.account.id);
+ assert.equal(note.noteSource.content, "Hello **world**");
+ assert.equal(note.actor.id, author.actor.id);
+ assert.equal(note.noteSourceId, note.noteSource.id);
+ assert.match(note.contentHtml, /world<\/strong>/);
+ assert.deepEqual(note.media, []);
+
+ const timelineItem = await tx.query.timelineItemTable.findFirst({
+ where: {
+ accountId: author.account.id,
+ postId: note.id,
+ },
+ });
+ assert.ok(timelineItem != null);
+ assert.equal(timelineItem.originalAuthorId, author.actor.id);
+ assert.equal(timelineItem.lastSharerId, null);
+ assert.equal(timelineItem.sharersCount, 0);
+ });
+});
+
+test("updateNote() updates the persisted post for an existing note source", async () => {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const author = await insertAccountWithActor(tx, {
+ username: "updatenoteauthor",
+ name: "Update Note Author",
+ email: "updatenoteauthor@example.com",
+ });
+ const original = await createNote(
+ fedCtx as unknown as Context>,
+ {
+ accountId: author.account.id,
+ visibility: "public",
+ content: "Original note body",
+ language: "en",
+ media: [],
+ published: new Date("2026-04-15T00:00:00.000Z"),
+ updated: new Date("2026-04-15T00:00:00.000Z"),
+ },
+ );
+ assert.ok(original != null);
+
+ const updated = await updateNote(fedCtx, original.noteSource.id, {
+ content: "Updated _note_ body",
+ language: "ko",
+ });
+
+ assert.ok(updated != null);
+ assert.equal(updated.id, original.id);
+ assert.equal(updated.noteSource.id, original.noteSource.id);
+ assert.equal(updated.noteSource.content, "Updated _note_ body");
+ assert.equal(updated.noteSource.language, "ko");
+ assert.match(updated.contentHtml, /note<\/em>/);
+
+ const storedPost = await tx.query.postTable.findFirst({
+ where: { id: original.id },
+ });
+ assert.ok(storedPost != null);
+ assert.equal(storedPost.noteSourceId, original.noteSource.id);
+ assert.equal(storedPost.language, "ko");
+ assert.match(storedPost.contentHtml, /note<\/em>/);
+ });
+});
diff --git a/models/note.test.ts b/models/note.test.ts
new file mode 100644
index 00000000..26e7987a
--- /dev/null
+++ b/models/note.test.ts
@@ -0,0 +1,96 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { updateAccountData } from "./account.ts";
+import { createNoteSource, getNoteSource, updateNoteSource } from "./note.ts";
+import { noteMediumTable } from "./schema.ts";
+import {
+ insertAccountWithActor,
+ insertNotePost,
+ withRollback,
+} from "../test/postgres.ts";
+
+test("createNoteSource() and updateNoteSource() round-trip note sources", async () => {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "notesourceowner",
+ name: "Note Source Owner",
+ email: "notesourceowner@example.com",
+ });
+ const published = new Date("2026-04-15T00:00:00.000Z");
+
+ const created = await createNoteSource(tx, {
+ accountId: account.account.id,
+ visibility: "unlisted",
+ content: "Original note source",
+ language: "en",
+ published,
+ updated: published,
+ });
+
+ assert.ok(created != null);
+ assert.equal(created.accountId, account.account.id);
+ assert.equal(created.visibility, "unlisted");
+ assert.equal(created.content, "Original note source");
+ assert.equal(created.language, "en");
+
+ const updated = await updateNoteSource(tx, created.id, {
+ content: "Updated note source",
+ language: "ko",
+ visibility: "followers",
+ });
+
+ assert.ok(updated != null);
+ assert.equal(updated.id, created.id);
+ assert.equal(updated.content, "Updated note source");
+ assert.equal(updated.language, "ko");
+ assert.equal(updated.visibility, "followers");
+ assert.equal(updated.published.toISOString(), published.toISOString());
+ assert.equal(updated.updated.getTime() >= created.updated.getTime(), true);
+ });
+});
+
+test("getNoteSource() resolves renamed accounts and loads media relations", async () => {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "oldnoteuser",
+ name: "Old Note User",
+ email: "oldnoteuser@example.com",
+ });
+ const { noteSourceId, post } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Readable note source",
+ });
+
+ await tx.insert(noteMediumTable).values({
+ sourceId: noteSourceId,
+ index: 0,
+ key: "note-media/test.webp",
+ alt: "Readable alt text",
+ width: 320,
+ height: 180,
+ });
+
+ const renamed = await updateAccountData(tx, {
+ id: author.account.id,
+ username: "newnoteuser",
+ });
+ assert.ok(renamed != null);
+
+ const source = await getNoteSource(
+ tx,
+ "oldnoteuser",
+ noteSourceId,
+ undefined,
+ );
+
+ assert.ok(source != null);
+ assert.equal(source.id, noteSourceId);
+ assert.equal(source.account.id, author.account.id);
+ assert.equal(source.account.username, "newnoteuser");
+ assert.equal(source.post.id, post.id);
+ assert.equal(source.post.actor.id, author.actor.id);
+ assert.equal(source.media.length, 1);
+ assert.equal(source.media[0].key, "note-media/test.webp");
+ assert.equal(source.media[0].alt, "Readable alt text");
+ });
+});
diff --git a/models/notification.ordering.test.ts b/models/notification.ordering.test.ts
new file mode 100644
index 00000000..0043589e
--- /dev/null
+++ b/models/notification.ordering.test.ts
@@ -0,0 +1,251 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { createShareNotification } from "./notification.ts";
+import {
+ insertAccountWithActor,
+ insertNotePost,
+ withRollback,
+} from "../test/postgres.ts";
+
+test("createShareNotification() keeps the newest created time when older events merge later", async () => {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "orderingauthor",
+ name: "Ordering Author",
+ email: "orderingauthor@example.com",
+ });
+ const newerSharer = await insertAccountWithActor(tx, {
+ username: "newersharer",
+ name: "Newer Sharer",
+ email: "newersharer@example.com",
+ });
+ const olderSharer = await insertAccountWithActor(tx, {
+ username: "oldersharer",
+ name: "Older Sharer",
+ email: "oldersharer@example.com",
+ });
+ const { post } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Notification ordering target",
+ });
+ const newer = new Date("2026-04-15T01:00:00.000Z");
+ const older = new Date("2026-04-15T00:00:00.000Z");
+
+ await createShareNotification(
+ tx,
+ author.account.id,
+ post,
+ newerSharer.actor,
+ newer,
+ );
+ const merged = await createShareNotification(
+ tx,
+ author.account.id,
+ post,
+ olderSharer.actor,
+ older,
+ );
+
+ assert.ok(merged != null);
+ assert.equal(merged.created.toISOString(), newer.toISOString());
+
+ const stored = await tx.query.notificationTable.findFirst({
+ where: {
+ accountId: author.account.id,
+ type: "share",
+ postId: post.id,
+ },
+ });
+ assert.ok(stored != null);
+ assert.equal(stored.created.toISOString(), newer.toISOString());
+ });
+});
+
+test("createShareNotification() keeps the existing row unchanged for replayed same-actor events", async () => {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "replayauthor",
+ name: "Replay Author",
+ email: "replayauthor@example.com",
+ });
+ const sharer = await insertAccountWithActor(tx, {
+ username: "replaysharer",
+ name: "Replay Sharer",
+ email: "replaysharer@example.com",
+ });
+ const { post } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Notification replay target",
+ });
+ const newer = new Date("2026-04-15T01:00:00.000Z");
+ const older = new Date("2026-04-15T00:00:00.000Z");
+
+ const created = await createShareNotification(
+ tx,
+ author.account.id,
+ post,
+ sharer.actor,
+ newer,
+ );
+ const replayed = await createShareNotification(
+ tx,
+ author.account.id,
+ post,
+ sharer.actor,
+ older,
+ );
+
+ assert.ok(created != null);
+ assert.ok(replayed != null);
+ assert.equal(replayed.id, created.id);
+ assert.equal(replayed.created.toISOString(), newer.toISOString());
+ assert.deepEqual(replayed.actorIds, [sharer.actor.id]);
+
+ const stored = await tx.query.notificationTable.findFirst({
+ where: {
+ accountId: author.account.id,
+ type: "share",
+ postId: post.id,
+ },
+ });
+ assert.ok(stored != null);
+ assert.equal(stored.created.toISOString(), newer.toISOString());
+ assert.deepEqual(stored.actorIds, [sharer.actor.id]);
+ });
+});
+
+test("createShareNotification() only merges the matching notification row", async () => {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "scopemergeauthor",
+ name: "Scope Merge Author",
+ email: "scopemergeauthor@example.com",
+ });
+ const firstSharer = await insertAccountWithActor(tx, {
+ username: "scopefirstsharer",
+ name: "Scope First Sharer",
+ email: "scopefirstsharer@example.com",
+ });
+ const secondSharer = await insertAccountWithActor(tx, {
+ username: "scopesecondsharer",
+ name: "Scope Second Sharer",
+ email: "scopesecondsharer@example.com",
+ });
+ const { post: firstPost } = await insertNotePost(tx, {
+ account: author.account,
+ content: "First scoped notification target",
+ });
+ const { post: secondPost } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Second scoped notification target",
+ });
+ const firstCreated = new Date("2026-04-15T00:00:00.000Z");
+ const secondCreated = new Date("2026-04-15T00:30:00.000Z");
+ const mergedCreated = new Date("2026-04-15T01:00:00.000Z");
+
+ const first = await createShareNotification(
+ tx,
+ author.account.id,
+ firstPost,
+ firstSharer.actor,
+ firstCreated,
+ );
+ const second = await createShareNotification(
+ tx,
+ author.account.id,
+ secondPost,
+ secondSharer.actor,
+ secondCreated,
+ );
+ const merged = await createShareNotification(
+ tx,
+ author.account.id,
+ firstPost,
+ secondSharer.actor,
+ mergedCreated,
+ );
+
+ assert.ok(first != null);
+ assert.ok(second != null);
+ assert.ok(merged != null);
+ assert.equal(merged.id, first.id);
+
+ const storedFirst = await tx.query.notificationTable.findFirst({
+ where: { id: first.id },
+ });
+ const storedSecond = await tx.query.notificationTable.findFirst({
+ where: { id: second.id },
+ });
+ assert.ok(storedFirst != null);
+ assert.ok(storedSecond != null);
+ assert.equal(
+ storedFirst.created.toISOString(),
+ mergedCreated.toISOString(),
+ );
+ assert.deepEqual(storedFirst.actorIds, [
+ firstSharer.actor.id,
+ secondSharer.actor.id,
+ ]);
+ assert.equal(
+ storedSecond.created.toISOString(),
+ secondCreated.toISOString(),
+ );
+ assert.deepEqual(storedSecond.actorIds, [secondSharer.actor.id]);
+ });
+});
+
+test("createShareNotification() keeps both actors when concurrent inserts race", async () => {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "concurrentauthor",
+ name: "Concurrent Author",
+ email: "concurrentauthor@example.com",
+ });
+ const firstSharer = await insertAccountWithActor(tx, {
+ username: "concurrentfirst",
+ name: "Concurrent First",
+ email: "concurrentfirst@example.com",
+ });
+ const secondSharer = await insertAccountWithActor(tx, {
+ username: "concurrentsecond",
+ name: "Concurrent Second",
+ email: "concurrentsecond@example.com",
+ });
+ const { post } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Concurrent notification target",
+ });
+
+ const [first, second] = await Promise.all([
+ createShareNotification(
+ tx,
+ author.account.id,
+ post,
+ firstSharer.actor,
+ ),
+ createShareNotification(
+ tx,
+ author.account.id,
+ post,
+ secondSharer.actor,
+ ),
+ ]);
+
+ assert.ok(first != null);
+ assert.ok(second != null);
+ assert.equal(first.id, second.id);
+
+ const stored = await tx.query.notificationTable.findFirst({
+ where: {
+ accountId: author.account.id,
+ type: "share",
+ postId: post.id,
+ },
+ });
+ assert.ok(stored != null);
+ assert.deepEqual(
+ [...stored.actorIds].sort(),
+ [firstSharer.actor.id, secondSharer.actor.id].sort(),
+ );
+ });
+});
diff --git a/models/notification.test.ts b/models/notification.test.ts
new file mode 100644
index 00000000..6e438bad
--- /dev/null
+++ b/models/notification.test.ts
@@ -0,0 +1,286 @@
+import { assert } from "@std/assert/assert";
+import { assertEquals } from "@std/assert/equals";
+import {
+ createFollowNotification,
+ createShareNotification,
+ deleteShareNotification,
+ getNotifications,
+} from "./notification.ts";
+import {
+ insertAccountWithActor,
+ insertNotePost,
+ withRollback,
+} from "../test/postgres.ts";
+
+Deno.test({
+ name: "createShareNotification() merges repeated shares into one row",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "notifyauthor",
+ name: "Notify Author",
+ email: "notifyauthor@example.com",
+ });
+ const firstSharer = await insertAccountWithActor(tx, {
+ username: "firstsharer",
+ name: "First Sharer",
+ email: "firstsharer@example.com",
+ });
+ const secondSharer = await insertAccountWithActor(tx, {
+ username: "secondsharer",
+ name: "Second Sharer",
+ email: "secondsharer@example.com",
+ });
+ const { post } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Shared target",
+ });
+ const older = new Date("2026-04-15T00:00:00.000Z");
+ const newer = new Date("2026-04-15T01:00:00.000Z");
+
+ const firstNotification = await createShareNotification(
+ tx,
+ author.account.id,
+ post,
+ firstSharer.actor,
+ older,
+ );
+ const secondNotification = await createShareNotification(
+ tx,
+ author.account.id,
+ post,
+ secondSharer.actor,
+ newer,
+ );
+
+ assert(firstNotification != null);
+ assert(secondNotification != null);
+ assertEquals(secondNotification.id, firstNotification.id);
+
+ const storedNotifications = await tx.query.notificationTable.findMany({
+ where: {
+ accountId: author.account.id,
+ type: "share",
+ postId: post.id,
+ },
+ });
+ assertEquals(storedNotifications.length, 1);
+ assertEquals(storedNotifications[0].actorIds, [
+ firstSharer.actor.id,
+ secondSharer.actor.id,
+ ]);
+ assertEquals(
+ storedNotifications[0].created.toISOString(),
+ newer.toISOString(),
+ );
+ });
+ },
+});
+
+Deno.test({
+ name: "deleteShareNotification() prunes merged actors and deletes empty rows",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const author = await insertAccountWithActor(tx, {
+ username: "deletenotifyauthor",
+ name: "Delete Notify Author",
+ email: "deletenotifyauthor@example.com",
+ });
+ const firstSharer = await insertAccountWithActor(tx, {
+ username: "deletefirstsharer",
+ name: "Delete First Sharer",
+ email: "deletefirstsharer@example.com",
+ });
+ const secondSharer = await insertAccountWithActor(tx, {
+ username: "deletesecondsharer",
+ name: "Delete Second Sharer",
+ email: "deletesecondsharer@example.com",
+ });
+ const { post } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Delete shared target",
+ });
+
+ await createShareNotification(
+ tx,
+ author.account.id,
+ post,
+ firstSharer.actor,
+ );
+ await createShareNotification(
+ tx,
+ author.account.id,
+ post,
+ secondSharer.actor,
+ );
+
+ const pruned = await deleteShareNotification(
+ tx,
+ author.account.id,
+ post,
+ firstSharer.actor,
+ );
+
+ assert(pruned != null);
+ assertEquals(pruned.actorIds, [secondSharer.actor.id]);
+
+ const remainingNotification = await tx.query.notificationTable.findFirst({
+ where: {
+ accountId: author.account.id,
+ type: "share",
+ postId: post.id,
+ },
+ });
+ assert(remainingNotification != null);
+ assertEquals(remainingNotification.actorIds, [secondSharer.actor.id]);
+
+ const deleted = await deleteShareNotification(
+ tx,
+ author.account.id,
+ post,
+ secondSharer.actor,
+ );
+
+ assert(deleted != null);
+ assertEquals(deleted.actorIds, []);
+
+ const removedNotification = await tx.query.notificationTable.findFirst({
+ where: {
+ accountId: author.account.id,
+ type: "share",
+ postId: post.id,
+ },
+ });
+ assertEquals(removedNotification, undefined);
+ });
+ },
+});
+
+Deno.test({
+ name: "getNotifications() returns newest notifications with loaded relations",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const recipient = await insertAccountWithActor(tx, {
+ username: "notificationrecipient",
+ name: "Notification Recipient",
+ email: "notificationrecipient@example.com",
+ });
+ const follower = await insertAccountWithActor(tx, {
+ username: "notificationfollower",
+ name: "Notification Follower",
+ email: "notificationfollower@example.com",
+ });
+ const sharer = await insertAccountWithActor(tx, {
+ username: "notificationsharer",
+ name: "Notification Sharer",
+ email: "notificationsharer@example.com",
+ });
+ const { post } = await insertNotePost(tx, {
+ account: recipient.account,
+ content: "Shared in notification list",
+ });
+
+ await createShareNotification(
+ tx,
+ recipient.account.id,
+ post,
+ sharer.actor,
+ new Date("2026-04-15T00:00:00.000Z"),
+ );
+ await createFollowNotification(
+ tx,
+ recipient.account.id,
+ follower.actor,
+ new Date("2026-04-15T01:00:00.000Z"),
+ );
+
+ const notifications = await getNotifications(
+ tx,
+ recipient.account.id,
+ new Date("2026-04-15T23:59:59.000Z"),
+ );
+
+ assertEquals(notifications.length, 2);
+ assertEquals(notifications[0].type, "follow");
+ assertEquals(notifications[0].post, null);
+ assertEquals(notifications[0].account.id, recipient.account.id);
+
+ assertEquals(notifications[1].type, "share");
+ assert(notifications[1].post != null);
+ assertEquals(notifications[1].post.id, post.id);
+ assertEquals(notifications[1].post.actor.id, recipient.actor.id);
+ assertEquals(notifications[1].post.actor.instance.host, "localhost");
+ assertEquals(notifications[1].account.id, recipient.account.id);
+ assertEquals(notifications[1].customEmoji, null);
+ });
+ },
+});
+
+Deno.test({
+ name:
+ "createFollowNotification() returns the existing row for duplicate follows",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const recipient = await insertAccountWithActor(tx, {
+ username: "followrecipient",
+ name: "Follow Recipient",
+ email: "followrecipient@example.com",
+ });
+ const firstFollower = await insertAccountWithActor(tx, {
+ username: "firstfollower",
+ name: "First Follower",
+ email: "firstfollower@example.com",
+ });
+ const secondFollower = await insertAccountWithActor(tx, {
+ username: "secondfollower",
+ name: "Second Follower",
+ email: "secondfollower@example.com",
+ });
+
+ const first = await createFollowNotification(
+ tx,
+ recipient.account.id,
+ firstFollower.actor,
+ new Date("2026-04-15T00:00:00.000Z"),
+ );
+ const second = await createFollowNotification(
+ tx,
+ recipient.account.id,
+ secondFollower.actor,
+ new Date("2026-04-15T01:00:00.000Z"),
+ );
+ const duplicate = await createFollowNotification(
+ tx,
+ recipient.account.id,
+ firstFollower.actor,
+ new Date("2026-04-15T02:00:00.000Z"),
+ );
+
+ assert(first != null);
+ assert(second != null);
+ assert(duplicate != null);
+ assertEquals(duplicate.id, first.id);
+
+ const notifications = await tx.query.notificationTable.findMany({
+ where: {
+ accountId: recipient.account.id,
+ type: "follow",
+ },
+ orderBy: { created: "asc" },
+ });
+ assertEquals(notifications.length, 2);
+ assertEquals(
+ notifications.map((notification) => notification.actorIds),
+ [[firstFollower.actor.id], [secondFollower.actor.id]],
+ );
+ });
+ },
+});
diff --git a/models/notification.ts b/models/notification.ts
index 3bc40225..64862bf8 100644
--- a/models/notification.ts
+++ b/models/notification.ts
@@ -1,6 +1,5 @@
import { getLogger } from "@logtape/logtape";
import { and, eq, isNull, sql } from "drizzle-orm";
-import postgres from "postgres";
import { type ApnsNotificationOptions, sendApnsNotification } from "./apns.ts";
import type { Database } from "./db.ts";
import {
@@ -55,6 +54,84 @@ export async function createNotification(
emoji?: string | CustomEmoji | null,
): Promise {
const postId = post?.id;
+ const effectiveCreated = created == null
+ ? sql`CURRENT_TIMESTAMP`
+ : sql`${created.toISOString()}::timestamptz`;
+ const explicitCreatedIsNewer = created == null
+ ? sql`FALSE`
+ : sql`${created.toISOString()}::timestamptz > ${notificationTable.created}`;
+ const getExistingFollowNotification = async () => {
+ const rows = await db.select().from(notificationTable)
+ .where(sql`
+ ${notificationTable.accountId} = ${accountId}
+ AND ${notificationTable.type} = 'follow'
+ AND ${notificationTable.actorIds} = ARRAY[${actorId}]::uuid[]
+ `);
+ return rows[0];
+ };
+ const getExistingNotification = async () => {
+ const rows = await db.select().from(notificationTable)
+ .where(notificationWhere);
+ return rows[0];
+ };
+ const notificationWhere = and(
+ eq(notificationTable.accountId, accountId),
+ eq(notificationTable.type, type),
+ post == null
+ ? isNull(notificationTable.postId)
+ : eq(notificationTable.postId, post.id),
+ emoji == null
+ ? undefined
+ : typeof emoji === "string"
+ ? eq(notificationTable.emoji, emoji)
+ : eq(notificationTable.customEmojiId, emoji.id),
+ );
+ const mergeableNotificationWhere = and(
+ notificationWhere,
+ sql`(NOT (${actorId} = ANY(${notificationTable.actorIds})) OR ${explicitCreatedIsNewer})`,
+ );
+
+ // Follow notifications are unique by actorIds, not by post/emoji fields, so
+ // they need an exact actorIds lookup instead of the generic merge/update
+ // path used by notifications that collapse multiple actors into one row.
+ if (type === "follow") {
+ const existing = await getExistingFollowNotification();
+ if (existing != null) {
+ return existing;
+ }
+ }
+
+ // Update first so mergeable notifications still work inside an existing
+ // transaction, where a unique-violation insert would abort the transaction.
+ if (type !== "follow") {
+ const mergedRows = await db.update(notificationTable)
+ .set({
+ actorIds: sql`
+ CASE
+ WHEN ${actorId} = ANY(${notificationTable.actorIds})
+ THEN ${notificationTable.actorIds}
+ ELSE array_append(${notificationTable.actorIds}, ${actorId})
+ END
+ `,
+ created:
+ sql`GREATEST(${notificationTable.created}, ${effectiveCreated})`,
+ })
+ .where(mergeableNotificationWhere)
+ .returning();
+ const merged = mergedRows[0];
+ if (merged != null) {
+ await sendApnsNotificationBestEffort(db, {
+ accountId,
+ notificationId: merged.id,
+ type,
+ actorId,
+ postId,
+ emoji,
+ });
+ return merged;
+ }
+ }
+
try {
const id = generateUuidV7();
const notificationRows = await db.insert(notificationTable)
@@ -70,58 +147,53 @@ export async function createNotification(
: emoji.id,
created: created ?? sql`CURRENT_TIMESTAMP`,
})
+ .onConflictDoNothing()
.returning();
- const notification = notificationRows[0];
- if (notification != null) {
+ const inserted = notificationRows[0];
+ if (inserted != null) {
await sendApnsNotificationBestEffort(db, {
accountId,
- notificationId: notification.id,
+ notificationId: inserted.id,
type,
actorId,
postId,
emoji,
});
+ return inserted;
}
- return notification;
- } catch (error) {
- if (error instanceof postgres.PostgresError && error.code === "23505") {
- const notificationRows = await db.update(notificationTable)
- .set({
- actorIds:
- sql`array_append(${notificationTable.actorIds}, ${actorId})`,
- created: created ?? sql`CURRENT_TIMESTAMP`,
- })
- .where(
- and(
- eq(notificationTable.accountId, accountId),
- eq(notificationTable.type, type),
- post == null
- ? isNull(notificationTable.postId)
- : eq(notificationTable.postId, post.id),
- emoji == null
- ? undefined
- : typeof emoji === "string"
- ? eq(notificationTable.emoji, emoji)
- : eq(notificationTable.customEmojiId, emoji.id),
- ),
- )
- .returning();
- const notification = notificationRows[0];
- if (notification != null) {
- await sendApnsNotificationBestEffort(db, {
- accountId,
- notificationId: notification.id,
- type,
- actorId,
- postId,
- emoji,
- });
- }
- return notification;
- } else {
- logger.error("Failed to create notification: {error}", { error });
- return undefined;
+ if (type === "follow") {
+ return await getExistingFollowNotification();
+ }
+ const mergedRows = await db.update(notificationTable)
+ .set({
+ actorIds: sql`
+ CASE
+ WHEN ${actorId} = ANY(${notificationTable.actorIds})
+ THEN ${notificationTable.actorIds}
+ ELSE array_append(${notificationTable.actorIds}, ${actorId})
+ END
+ `,
+ created:
+ sql`GREATEST(${notificationTable.created}, ${effectiveCreated})`,
+ })
+ .where(mergeableNotificationWhere)
+ .returning();
+ const merged = mergedRows[0];
+ if (merged != null) {
+ await sendApnsNotificationBestEffort(db, {
+ accountId,
+ notificationId: merged.id,
+ type,
+ actorId,
+ postId,
+ emoji,
+ });
+ return merged;
}
+ return await getExistingNotification();
+ } catch (error) {
+ logger.error("Failed to create notification: {error}", { error });
+ return undefined;
}
}
diff --git a/models/passkey.test.ts b/models/passkey.test.ts
new file mode 100644
index 00000000..e3c001f3
--- /dev/null
+++ b/models/passkey.test.ts
@@ -0,0 +1,171 @@
+import { assert } from "@std/assert/assert";
+import { assertEquals } from "@std/assert/equals";
+import { assertRejects } from "@std/assert/rejects";
+import { Buffer } from "node:buffer";
+import {
+ getAuthenticationOptions,
+ getRegistrationOptions,
+ resolvePasskeyOrigin,
+ verifyAuthentication,
+ verifyRegistration,
+} from "./passkey.ts";
+import {
+ createTestKv,
+ insertAccountWithActor,
+ withRollback,
+} from "../test/postgres.ts";
+
+Deno.test("resolvePasskeyOrigin() prefers platform-specific origins", () => {
+ assertEquals(
+ resolvePasskeyOrigin("https://pub.hackers.pub/sign/in", "web"),
+ "https://pub.hackers.pub",
+ );
+ assertEquals(
+ resolvePasskeyOrigin("https://pub.hackers.pub/sign/in", "ios"),
+ "https://pub.hackers.pub",
+ );
+ assertEquals(
+ resolvePasskeyOrigin("https://pub.hackers.pub/sign/in", "android"),
+ "android:apk-key-hash:UqAUIQLNMP2LKaPtgCsKvq-rNyl5OYQat545Ba9k1Ro",
+ );
+});
+
+Deno.test({
+ name:
+ "getRegistrationOptions() stores a challenge and excludes existing credentials",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const { kv, store } = createTestKv();
+ const account = await insertAccountWithActor(tx, {
+ username: "passkeymodelowner",
+ name: "Passkey Model Owner",
+ email: "passkeymodelowner@example.com",
+ });
+
+ const options = await getRegistrationOptions(
+ kv,
+ "https://pub.hackers.pub/sign/in",
+ {
+ ...account.account,
+ passkeys: [
+ {
+ id: "credential-id",
+ accountId: account.account.id,
+ name: "Laptop",
+ publicKey: Buffer.from([1, 2, 3]),
+ webauthnUserId: "webauthn-user",
+ counter: 0n,
+ deviceType: "singleDevice",
+ backedUp: false,
+ transports: ["internal"],
+ lastUsed: null,
+ created: new Date("2026-04-15T00:00:00.000Z"),
+ },
+ ],
+ },
+ );
+
+ assert(options.challenge.length > 0);
+ assertEquals(options.rp.id, "pub.hackers.pub");
+ assertEquals(options.user.name, "passkeymodelowner");
+ assertEquals(options.excludeCredentials, [{
+ id: "credential-id",
+ type: "public-key",
+ transports: ["internal"],
+ }]);
+ assert(store.has(`passkey/registration/${account.account.id}`));
+ });
+ },
+});
+
+Deno.test({
+ name: "verifyRegistration() fails when registration options are missing",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const { kv } = createTestKv();
+ const account = await insertAccountWithActor(tx, {
+ username: "missingregistration",
+ name: "Missing Registration",
+ email: "missingregistration@example.com",
+ });
+
+ await assertRejects(
+ () =>
+ verifyRegistration(
+ tx,
+ kv,
+ "https://pub.hackers.pub",
+ "pub.hackers.pub",
+ account.account,
+ "Laptop",
+ { id: "credential-id" } as never,
+ ),
+ Error,
+ `Missing registration options for account ${account.account.id}.`,
+ );
+ });
+ },
+});
+
+Deno.test({
+ name: "getAuthenticationOptions() stores a challenge for the session",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ const { kv, store } = createTestKv();
+ const sessionId = "019d9162-ffff-7fff-8fff-ffffffffffff";
+
+ const options = await getAuthenticationOptions(
+ kv,
+ "https://pub.hackers.pub/sign/in",
+ sessionId,
+ );
+
+ assert(options.challenge.length > 0);
+ assertEquals(options.rpId, "pub.hackers.pub");
+ assert(store.has(`passkey/authentication/${sessionId}`));
+ },
+});
+
+Deno.test({
+ name:
+ "verifyAuthentication() returns undefined for missing options or credentials",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const { kv } = createTestKv();
+ const sessionId = "019d9162-eeee-7eee-8eee-eeeeeeeeeeee";
+
+ const missingOptions = await verifyAuthentication(
+ tx,
+ kv,
+ "https://pub.hackers.pub",
+ "pub.hackers.pub",
+ sessionId,
+ { id: "missing-passkey" } as never,
+ );
+ assertEquals(missingOptions, undefined);
+
+ await getAuthenticationOptions(
+ kv,
+ "https://pub.hackers.pub/sign/in",
+ sessionId,
+ );
+
+ const missingPasskey = await verifyAuthentication(
+ tx,
+ kv,
+ "https://pub.hackers.pub",
+ "pub.hackers.pub",
+ sessionId,
+ { id: "missing-passkey" } as never,
+ );
+ assertEquals(missingPasskey, undefined);
+ });
+ },
+});
diff --git a/models/poll.test.ts b/models/poll.test.ts
new file mode 100644
index 00000000..9c8c6b1e
--- /dev/null
+++ b/models/poll.test.ts
@@ -0,0 +1,246 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import * as vocab from "@fedify/vocab";
+import type { Transaction } from "./db.ts";
+import { persistPollVote, vote } from "./poll.ts";
+import {
+ type NewPost,
+ type Poll,
+ type PollOption,
+ pollOptionTable,
+ pollTable,
+ postTable,
+} from "./schema.ts";
+import { generateUuidV7 } from "./uuid.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ insertRemoteActor,
+ withRollback,
+} from "../test/postgres.ts";
+
+type InsertQuestionPollResult = {
+ post: NonNullable<
+ Awaited>
+ >;
+ poll: Poll & { options: PollOption[] };
+};
+
+async function insertQuestionPoll(
+ tx: Transaction,
+ values: {
+ account: Awaited>["account"];
+ multiple: boolean;
+ optionTitles: string[];
+ ends?: Date;
+ },
+): Promise {
+ const postId = generateUuidV7();
+ const published = new Date();
+ const oneDayMs = 24 * 60 * 60 * 1000;
+
+ await tx.insert(postTable).values(
+ {
+ id: postId,
+ iri: `http://localhost/objects/${postId}`,
+ type: "Question",
+ visibility: "public",
+ actorId: values.account.actor.id,
+ name: "Poll question",
+ contentHtml: "Poll question
",
+ language: "en",
+ tags: {},
+ emojis: {},
+ url: `http://localhost/@${values.account.username}/polls/${postId}`,
+ published,
+ updated: published,
+ } satisfies NewPost,
+ );
+
+ const post = await tx.query.postTable.findFirst({ where: { id: postId } });
+ assert.ok(post != null);
+
+ await tx.insert(pollTable).values({
+ postId: post.id,
+ multiple: values.multiple,
+ votersCount: 0,
+ ends: values.ends ?? new Date(published.getTime() + oneDayMs),
+ });
+ await tx.insert(pollOptionTable).values(
+ values.optionTitles.map((title, index) => ({
+ postId: post.id,
+ index,
+ title,
+ votesCount: 0,
+ })),
+ );
+
+ const poll = await tx.query.pollTable.findFirst({
+ where: { postId: post.id },
+ with: { options: true },
+ });
+ assert.ok(poll != null);
+
+ return { post, poll };
+}
+
+test("vote() stores a single-choice vote and stays idempotent", async () => {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const author = await insertAccountWithActor(tx, {
+ username: "pollauthor",
+ name: "Poll Author",
+ email: "pollauthor@example.com",
+ });
+ const voter = await insertAccountWithActor(tx, {
+ username: "pollvoter",
+ name: "Poll Voter",
+ email: "pollvoter@example.com",
+ });
+ const { poll } = await insertQuestionPoll(tx, {
+ account: author.account,
+ multiple: false,
+ optionTitles: ["TypeScript", "Rust"],
+ });
+
+ const firstVote = await vote(fedCtx, voter.account, poll, new Set([1]));
+
+ assert.equal(firstVote.length, 1);
+ assert.equal(firstVote[0].optionIndex, 1);
+
+ const storedPoll = await tx.query.pollTable.findFirst({
+ where: { postId: poll.postId },
+ });
+ assert.ok(storedPoll != null);
+ assert.equal(storedPoll.votersCount, 1);
+
+ const storedOptions = await tx.query.pollOptionTable.findMany({
+ where: { postId: poll.postId },
+ orderBy: { index: "asc" },
+ });
+ assert.deepEqual(
+ storedOptions.map((option) => option.votesCount),
+ [0, 1],
+ );
+
+ const repeatedVote = await vote(fedCtx, voter.account, poll, new Set([0]));
+
+ assert.equal(repeatedVote.length, 1);
+ assert.equal(repeatedVote[0].optionIndex, 1);
+
+ const pollAfterRepeat = await tx.query.pollTable.findFirst({
+ where: { postId: poll.postId },
+ });
+ assert.ok(pollAfterRepeat != null);
+ assert.equal(pollAfterRepeat.votersCount, 1);
+ });
+});
+
+test("vote() rejects multiple choices for single polls and allows them for multi polls", async () => {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const author = await insertAccountWithActor(tx, {
+ username: "multiauthor",
+ name: "Multi Author",
+ email: "multiauthor@example.com",
+ });
+ const singleVoter = await insertAccountWithActor(tx, {
+ username: "singlevoter",
+ name: "Single Voter",
+ email: "singlevoter@example.com",
+ });
+ const multiVoter = await insertAccountWithActor(tx, {
+ username: "multivoter",
+ name: "Multi Voter",
+ email: "multivoter@example.com",
+ });
+ const { poll: singlePoll } = await insertQuestionPoll(tx, {
+ account: author.account,
+ multiple: false,
+ optionTitles: ["One", "Two"],
+ });
+ const { poll: multiPoll } = await insertQuestionPoll(tx, {
+ account: author.account,
+ multiple: true,
+ optionTitles: ["Red", "Blue", "Green"],
+ });
+
+ const rejected = await vote(
+ fedCtx,
+ singleVoter.account,
+ singlePoll,
+ new Set([0, 1]),
+ );
+ assert.deepEqual(rejected, []);
+
+ const accepted = await vote(
+ fedCtx,
+ multiVoter.account,
+ multiPoll,
+ new Set([0, 2]),
+ );
+ assert.equal(accepted.length, 2);
+ assert.deepEqual(
+ accepted.map((entry) => entry.optionIndex).sort((a, b) => a - b),
+ [0, 2],
+ );
+
+ const storedMultiPoll = await tx.query.pollTable.findFirst({
+ where: { postId: multiPoll.postId },
+ });
+ assert.ok(storedMultiPoll != null);
+ assert.equal(storedMultiPoll.votersCount, 1);
+
+ const multiOptions = await tx.query.pollOptionTable.findMany({
+ where: { postId: multiPoll.postId },
+ orderBy: { index: "asc" },
+ });
+ assert.deepEqual(
+ multiOptions.map((option) => option.votesCount),
+ [1, 0, 1],
+ );
+ });
+});
+
+test("persistPollVote() stores an incoming vote for a persisted poll", async () => {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const author = await insertAccountWithActor(tx, {
+ username: "persistpollauthor",
+ name: "Persist Poll Author",
+ email: "persistpollauthor@example.com",
+ });
+ const remoteVoter = await insertRemoteActor(tx, {
+ username: "persistpollvoter",
+ name: "Persist Poll Voter",
+ host: "remote.example",
+ iri: "https://remote.example/users/persistpollvoter",
+ });
+ const { poll, post } = await insertQuestionPoll(tx, {
+ account: author.account,
+ multiple: false,
+ optionTitles: ["TypeScript", "Rust"],
+ });
+
+ const voteNote = new vocab.Note({
+ id: new URL(`http://localhost/objects/${generateUuidV7()}`),
+ attribution: new URL(remoteVoter.iri),
+ name: "Rust",
+ replyTarget: new URL(post.iri),
+ });
+
+ const storedVote = await persistPollVote(fedCtx, voteNote);
+
+ assert.ok(storedVote != null);
+ assert.equal(storedVote.postId, poll.postId);
+ assert.equal(storedVote.actorId, remoteVoter.id);
+ assert.equal(storedVote.optionIndex, 1);
+
+ const votes = await tx.query.pollVoteTable.findMany({
+ where: { postId: poll.postId },
+ });
+ assert.equal(votes.length, 1);
+ assert.equal(votes[0].actorId, remoteVoter.id);
+ assert.equal(votes[0].optionIndex, 1);
+ });
+});
diff --git a/models/post.delete.test.ts b/models/post.delete.test.ts
new file mode 100644
index 00000000..056abbb5
--- /dev/null
+++ b/models/post.delete.test.ts
@@ -0,0 +1,205 @@
+import { assert } from "@std/assert/assert";
+import { assertEquals } from "@std/assert/equals";
+import { eq, inArray } from "drizzle-orm";
+import { deletePost, sharePost } from "./post.ts";
+import { noteSourceTable, notificationTable, postTable } from "./schema.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ insertNotePost,
+ withRollback,
+} from "../test/postgres.ts";
+
+Deno.test({
+ name: "deletePost() removes a reply and decrements the parent reply count",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const author = await insertAccountWithActor(tx, {
+ username: "replyparent",
+ name: "Reply Parent",
+ email: "replyparent@example.com",
+ });
+ const replier = await insertAccountWithActor(tx, {
+ username: "replychild",
+ name: "Reply Child",
+ email: "replychild@example.com",
+ });
+ const { post: rootPost } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Root post",
+ });
+ const { noteSourceId: replySourceId, post: replyPost } =
+ await insertNotePost(
+ tx,
+ {
+ account: replier.account,
+ content: "Reply post",
+ replyTargetId: rootPost.id,
+ },
+ );
+
+ await tx.update(postTable)
+ .set({ repliesCount: 1 })
+ .where(eq(postTable.id, rootPost.id));
+
+ await deletePost(fedCtx, {
+ ...replyPost,
+ actor: replier.actor,
+ replyTarget: rootPost,
+ });
+
+ const storedRoot = await tx.query.postTable.findFirst({
+ where: { id: rootPost.id },
+ });
+ assert(storedRoot != null);
+ assertEquals(storedRoot.repliesCount, 0);
+
+ const storedReply = await tx.query.postTable.findFirst({
+ where: { id: replyPost.id },
+ });
+ assertEquals(storedReply, undefined);
+
+ const replySource = await tx.query.noteSourceTable.findFirst({
+ where: { id: replySourceId },
+ });
+ assertEquals(replySource, undefined);
+ });
+ },
+});
+
+Deno.test({
+ name:
+ "deletePost() deletes a standalone post without querying unrelated originals",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const author = await insertAccountWithActor(tx, {
+ username: "standaloneauthor",
+ name: "Standalone Author",
+ email: "standaloneauthor@example.com",
+ });
+ const other = await insertAccountWithActor(tx, {
+ username: "standaloneother",
+ name: "Standalone Other",
+ email: "standaloneother@example.com",
+ });
+ const { noteSourceId, post } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Standalone post",
+ });
+ const { post: unrelated } = await insertNotePost(tx, {
+ account: other.account,
+ content: "Unrelated post",
+ });
+
+ await deletePost(fedCtx, {
+ ...post,
+ actor: author.actor,
+ replyTarget: null,
+ });
+
+ const deleted = await tx.query.postTable.findFirst({
+ where: { id: post.id },
+ });
+ assertEquals(deleted, undefined);
+
+ const deletedSource = await tx.query.noteSourceTable.findFirst({
+ where: { id: noteSourceId },
+ });
+ assertEquals(deletedSource, undefined);
+
+ const unrelatedPost = await tx.query.postTable.findFirst({
+ where: { id: unrelated.id },
+ });
+ assert(unrelatedPost != null);
+ assertEquals(unrelatedPost.id, unrelated.id);
+ });
+ },
+});
+
+Deno.test({
+ name:
+ "deletePost() cascades through local replies, shares, and notifications",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const author = await insertAccountWithActor(tx, {
+ username: "cascadeauthor",
+ name: "Cascade Author",
+ email: "cascadeauthor@example.com",
+ });
+ const replier = await insertAccountWithActor(tx, {
+ username: "cascadereplier",
+ name: "Cascade Replier",
+ email: "cascadereplier@example.com",
+ });
+ const sharer = await insertAccountWithActor(tx, {
+ username: "cascadesharer",
+ name: "Cascade Sharer",
+ email: "cascadesharer@example.com",
+ });
+ const { noteSourceId: rootSourceId, post: rootPost } =
+ await insertNotePost(
+ tx,
+ {
+ account: author.account,
+ content: "Cascade root",
+ },
+ );
+ const { noteSourceId: replySourceId, post: replyPost } =
+ await insertNotePost(
+ tx,
+ {
+ account: replier.account,
+ content: "Cascade reply",
+ replyTargetId: rootPost.id,
+ },
+ );
+
+ await tx.update(postTable)
+ .set({ repliesCount: 1 })
+ .where(eq(postTable.id, rootPost.id));
+
+ const share = await sharePost(fedCtx, sharer.account, {
+ ...rootPost,
+ actor: author.actor,
+ });
+
+ await deletePost(fedCtx, {
+ ...rootPost,
+ actor: author.actor,
+ replyTarget: null,
+ });
+
+ const remainingPosts = await tx.select({ id: postTable.id }).from(
+ postTable,
+ )
+ .where(inArray(postTable.id, [rootPost.id, replyPost.id, share.id]));
+ assertEquals(remainingPosts, []);
+
+ const remainingSources = await tx.select({ id: noteSourceTable.id })
+ .from(noteSourceTable)
+ .where(inArray(noteSourceTable.id, [rootSourceId, replySourceId]));
+ assertEquals(remainingSources, []);
+
+ const notification = await tx.query.notificationTable.findFirst({
+ where: {
+ accountId: author.account.id,
+ type: "share",
+ postId: rootPost.id,
+ },
+ });
+ assertEquals(notification, undefined);
+
+ const notificationRows = await tx.select().from(notificationTable);
+ assertEquals(notificationRows, []);
+ });
+ },
+});
diff --git a/models/post.lifecycle.test.ts b/models/post.lifecycle.test.ts
new file mode 100644
index 00000000..babe0781
--- /dev/null
+++ b/models/post.lifecycle.test.ts
@@ -0,0 +1,200 @@
+import { assert } from "@std/assert/assert";
+import { assertEquals } from "@std/assert/equals";
+import { and, eq } from "drizzle-orm";
+import { follow } from "./following.ts";
+import { sharePost, unsharePost } from "./post.ts";
+import { postTable } from "./schema.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ insertNotePost,
+ withRollback,
+} from "../test/postgres.ts";
+
+Deno.test({
+ name: "sharePost() creates a share, timeline entry, and notification",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const author = await insertAccountWithActor(tx, {
+ username: "shareauthor",
+ name: "Share Author",
+ email: "shareauthor@example.com",
+ });
+ const sharer = await insertAccountWithActor(tx, {
+ username: "sharer",
+ name: "Sharer",
+ email: "sharer@example.com",
+ });
+ const follower = await insertAccountWithActor(tx, {
+ username: "sharefollower",
+ name: "Share Follower",
+ email: "sharefollower@example.com",
+ });
+ const { post: originalPost } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Original post",
+ });
+
+ await follow(fedCtx, follower.account, sharer.actor);
+
+ const share = await sharePost(fedCtx, sharer.account, {
+ ...originalPost,
+ actor: author.actor,
+ });
+
+ assertEquals(share.sharedPostId, originalPost.id);
+
+ const storedOriginal = await tx.query.postTable.findFirst({
+ where: { id: originalPost.id },
+ });
+ assert(storedOriginal != null);
+ assertEquals(storedOriginal.sharesCount, 1);
+
+ const timelineItem = await tx.query.timelineItemTable.findFirst({
+ where: {
+ accountId: follower.account.id,
+ postId: originalPost.id,
+ },
+ });
+ assert(timelineItem != null);
+ assertEquals(timelineItem.originalAuthorId, null);
+ assertEquals(timelineItem.lastSharerId, sharer.actor.id);
+ assertEquals(timelineItem.sharersCount, 1);
+
+ const notification = await tx.query.notificationTable.findFirst({
+ where: {
+ accountId: author.account.id,
+ type: "share",
+ postId: originalPost.id,
+ },
+ });
+ assert(notification != null);
+ assertEquals(notification.actorIds, [sharer.actor.id]);
+ });
+ },
+});
+
+Deno.test({
+ name: "sharePost() is idempotent for duplicate shares",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const author = await insertAccountWithActor(tx, {
+ username: "dupshareauthor",
+ name: "Dup Share Author",
+ email: "dupshareauthor@example.com",
+ });
+ const sharer = await insertAccountWithActor(tx, {
+ username: "dupsharer",
+ name: "Dup Sharer",
+ email: "dupsharer@example.com",
+ });
+ const { post: originalPost } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Duplicate share target",
+ });
+
+ const first = await sharePost(fedCtx, sharer.account, {
+ ...originalPost,
+ actor: author.actor,
+ });
+ const second = await sharePost(fedCtx, sharer.account, {
+ ...originalPost,
+ actor: author.actor,
+ });
+
+ assertEquals(second.id, first.id);
+
+ const shares = await tx.query.postTable.findMany({
+ where: {
+ actorId: sharer.actor.id,
+ sharedPostId: originalPost.id,
+ },
+ });
+ assertEquals(shares.length, 1);
+
+ const storedOriginal = await tx.query.postTable.findFirst({
+ where: { id: originalPost.id },
+ });
+ assert(storedOriginal != null);
+ assertEquals(storedOriginal.sharesCount, 1);
+ });
+ },
+});
+
+Deno.test({
+ name: "unsharePost() removes the share, timeline entry, and notification",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const author = await insertAccountWithActor(tx, {
+ username: "unshareauthor",
+ name: "Unshare Author",
+ email: "unshareauthor@example.com",
+ });
+ const sharer = await insertAccountWithActor(tx, {
+ username: "unsharer",
+ name: "Unsharer",
+ email: "unsharer@example.com",
+ });
+ const follower = await insertAccountWithActor(tx, {
+ username: "unsharefollower",
+ name: "Unshare Follower",
+ email: "unsharefollower@example.com",
+ });
+ const { post: originalPost } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Unshare target",
+ });
+
+ await follow(fedCtx, follower.account, sharer.actor);
+ await sharePost(fedCtx, sharer.account, {
+ ...originalPost,
+ actor: author.actor,
+ });
+
+ const removed = await unsharePost(fedCtx, sharer.account, {
+ ...originalPost,
+ actor: author.actor,
+ });
+
+ assert(removed != null);
+
+ const shares = await tx.select().from(postTable).where(and(
+ eq(postTable.actorId, sharer.actor.id),
+ eq(postTable.sharedPostId, originalPost.id),
+ ));
+ assertEquals(shares, []);
+
+ const storedOriginal = await tx.query.postTable.findFirst({
+ where: { id: originalPost.id },
+ });
+ assert(storedOriginal != null);
+ assertEquals(storedOriginal.sharesCount, 0);
+
+ const timelineItem = await tx.query.timelineItemTable.findFirst({
+ where: {
+ accountId: follower.account.id,
+ postId: originalPost.id,
+ },
+ });
+ assertEquals(timelineItem, undefined);
+
+ const notification = await tx.query.notificationTable.findFirst({
+ where: {
+ accountId: author.account.id,
+ type: "share",
+ postId: originalPost.id,
+ },
+ });
+ assertEquals(notification, undefined);
+ });
+ },
+});
diff --git a/models/post.remote.test.ts b/models/post.remote.test.ts
new file mode 100644
index 00000000..3a90d926
--- /dev/null
+++ b/models/post.remote.test.ts
@@ -0,0 +1,142 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { eq } from "drizzle-orm";
+import {
+ deletePersistedPost,
+ deleteSharedPost,
+ getPostByUsernameAndId,
+} from "./post.ts";
+import { postTable } from "./schema.ts";
+import {
+ insertAccountWithActor,
+ insertNotePost,
+ insertRemoteActor,
+ insertRemotePost,
+ withRollback,
+} from "../test/postgres.ts";
+
+test("getPostByUsernameAndId() requires a full handle and returns a matching post", async () => {
+ await withRollback(async (tx) => {
+ const account = await insertAccountWithActor(tx, {
+ username: "getpostowner",
+ name: "Get Post Owner",
+ email: "getpostowner@example.com",
+ });
+ const { post } = await insertNotePost(tx, {
+ account: account.account,
+ content: "Lookup by handle",
+ });
+
+ assert.equal(
+ await getPostByUsernameAndId(
+ tx,
+ account.account.username,
+ post.id,
+ account.account,
+ ),
+ undefined,
+ );
+
+ const found = await getPostByUsernameAndId(
+ tx,
+ `${account.account.username}@localhost`,
+ post.id,
+ account.account,
+ );
+
+ assert.ok(found != null);
+ assert.equal(found.id, post.id);
+ assert.equal(found.actor.id, account.actor.id);
+ });
+});
+
+test("deletePersistedPost() removes a remote reply and decrements the parent reply count", async () => {
+ await withRollback(async (tx) => {
+ const remoteAuthorSuffix = crypto.randomUUID().replaceAll("-", "").slice(
+ 0,
+ 8,
+ );
+ const remoteActor = await insertRemoteActor(tx, {
+ username: `remoteauthor${remoteAuthorSuffix}`,
+ name: "Remote Author",
+ host: "remote.example",
+ });
+ const parent = await insertRemotePost(tx, {
+ actorId: remoteActor.id,
+ contentHtml: "Remote parent
",
+ });
+ const reply = await insertRemotePost(tx, {
+ actorId: remoteActor.id,
+ contentHtml: "Remote reply
",
+ replyTargetId: parent.id,
+ });
+ await tx.update(postTable)
+ .set({ repliesCount: 1 })
+ .where(eq(postTable.id, parent.id));
+
+ const deleted = await deletePersistedPost(
+ tx,
+ new URL(reply.iri),
+ new URL(remoteActor.iri),
+ );
+
+ assert.equal(deleted, true);
+ const remainingReply = await tx.query.postTable.findFirst({
+ where: { id: reply.id },
+ });
+ assert.equal(remainingReply, undefined);
+
+ const updatedParent = await tx.query.postTable.findFirst({
+ where: { id: parent.id },
+ });
+ assert.ok(updatedParent != null);
+ assert.equal(updatedParent.repliesCount, 0);
+ });
+});
+
+test("deleteSharedPost() removes a remote share and decrements the target share count", async () => {
+ await withRollback(async (tx) => {
+ const remoteSharerSuffix = crypto.randomUUID().replaceAll("-", "").slice(
+ 0,
+ 8,
+ );
+ const remoteActor = await insertRemoteActor(tx, {
+ username: `remotesharer${remoteSharerSuffix}`,
+ name: "Remote Sharer",
+ host: "remote.example",
+ });
+ const original = await insertRemotePost(tx, {
+ actorId: remoteActor.id,
+ contentHtml: "Original remote post
",
+ });
+ const share = await insertRemotePost(tx, {
+ actorId: remoteActor.id,
+ contentHtml: "Shared remote post
",
+ sharedPostId: original.id,
+ });
+ await tx.update(postTable)
+ .set({ sharesCount: 1 })
+ .where(eq(postTable.id, original.id));
+
+ const deletedShare = await deleteSharedPost(
+ tx,
+ new URL(share.iri),
+ new URL(remoteActor.iri),
+ );
+
+ assert.ok(deletedShare != null);
+ assert.equal(deletedShare.id, share.id);
+ assert.equal(deletedShare.actor.id, remoteActor.id);
+
+ const remainingShare = await tx.query.postTable.findFirst({
+ where: { id: share.id },
+ });
+ assert.equal(remainingShare, undefined);
+
+ const updatedOriginal = await tx.query.postTable.findFirst({
+ where: { id: original.id },
+ });
+ assert.ok(updatedOriginal != null);
+ assert.equal(updatedOriginal.sharesCount, 0);
+ });
+});
diff --git a/models/post.sync.test.ts b/models/post.sync.test.ts
new file mode 100644
index 00000000..5bbc9529
--- /dev/null
+++ b/models/post.sync.test.ts
@@ -0,0 +1,167 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { eq } from "drizzle-orm";
+import {
+ articleContentTable,
+ articleSourceTable,
+ noteSourceTable,
+} from "./schema.ts";
+import { syncPostFromArticleSource, syncPostFromNoteSource } from "./post.ts";
+import { generateUuidV7 } from "./uuid.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ insertNotePost,
+ withRollback,
+} from "../test/postgres.ts";
+
+test("syncPostFromArticleSource() upserts the post when source content changes", async () => {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const author = await insertAccountWithActor(tx, {
+ username: "syncarticleowner",
+ name: "Sync Article Owner",
+ email: "syncarticleowner@example.com",
+ });
+ const sourceId = generateUuidV7();
+ const published = new Date("2026-04-15T00:00:00.000Z");
+
+ await tx.insert(articleSourceTable).values({
+ id: sourceId,
+ accountId: author.account.id,
+ publishedYear: 2026,
+ slug: "sync-article",
+ tags: ["relay"],
+ allowLlmTranslation: false,
+ published,
+ updated: published,
+ });
+ await tx.insert(articleContentTable).values({
+ sourceId,
+ language: "en",
+ title: "Original article",
+ content: "Original body with #Relay",
+ published,
+ updated: published,
+ });
+
+ const source = await tx.query.articleSourceTable.findFirst({
+ where: { id: sourceId },
+ with: {
+ account: { with: { emails: true, links: true } },
+ contents: true,
+ },
+ });
+ assert.ok(source != null);
+
+ const created = await syncPostFromArticleSource(fedCtx, source);
+
+ assert.equal(created.articleSourceId, sourceId);
+ assert.equal(created.name, "Original article");
+ assert.match(created.contentHtml, /Original body/);
+ assert.ok("relay" in created.tags);
+
+ await tx.update(articleContentTable)
+ .set({ title: "Updated article", content: "Updated body" })
+ .where(eq(articleContentTable.sourceId, sourceId));
+ await tx.update(articleSourceTable)
+ .set({ updated: new Date("2026-04-15T01:00:00.000Z") })
+ .where(eq(articleSourceTable.id, sourceId));
+
+ const updatedSource = await tx.query.articleSourceTable.findFirst({
+ where: { id: sourceId },
+ with: {
+ account: { with: { emails: true, links: true } },
+ contents: true,
+ },
+ });
+ assert.ok(updatedSource != null);
+
+ const updated = await syncPostFromArticleSource(fedCtx, updatedSource);
+
+ assert.equal(updated.id, created.id);
+ assert.equal(updated.name, "Updated article");
+ assert.match(updated.contentHtml, /Updated body/);
+ });
+});
+
+test("syncPostFromNoteSource() upserts note posts and updates quote counts", async () => {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const author = await insertAccountWithActor(tx, {
+ username: "syncnoteowner",
+ name: "Sync Note Owner",
+ email: "syncnoteowner@example.com",
+ });
+ const quotedAuthor = await insertAccountWithActor(tx, {
+ username: "quotedowner",
+ name: "Quoted Owner",
+ email: "quotedowner@example.com",
+ });
+ const { post: quotedPost } = await insertNotePost(tx, {
+ account: quotedAuthor.account,
+ content: "Quoted target",
+ });
+
+ const noteSourceId = generateUuidV7();
+ const published = new Date("2026-04-15T00:00:00.000Z");
+ await tx.insert(noteSourceTable).values({
+ id: noteSourceId,
+ accountId: author.account.id,
+ visibility: "public",
+ content: "Hello #HackersPub",
+ language: "en",
+ published,
+ updated: published,
+ });
+
+ const noteSource = await tx.query.noteSourceTable.findFirst({
+ where: { id: noteSourceId },
+ with: {
+ account: { with: { emails: true, links: true } },
+ media: true,
+ },
+ });
+ assert.ok(noteSource != null);
+
+ const created = await syncPostFromNoteSource(fedCtx, noteSource, {
+ quotedPost: { ...quotedPost, actor: quotedAuthor.actor },
+ });
+
+ assert.equal(created.noteSourceId, noteSourceId);
+ assert.equal(created.quotedPost?.id, quotedPost.id);
+ assert.ok("hackerspub" in created.tags);
+
+ const quotedAfterCreate = await tx.query.postTable.findFirst({
+ where: { id: quotedPost.id },
+ });
+ assert.ok(quotedAfterCreate != null);
+ assert.equal(quotedAfterCreate.quotesCount, 1);
+
+ await tx.update(noteSourceTable)
+ .set({ content: "Updated note body" })
+ .where(eq(noteSourceTable.id, noteSourceId));
+
+ const updatedSource = await tx.query.noteSourceTable.findFirst({
+ where: { id: noteSourceId },
+ with: {
+ account: { with: { emails: true, links: true } },
+ media: true,
+ },
+ });
+ assert.ok(updatedSource != null);
+
+ const updated = await syncPostFromNoteSource(fedCtx, updatedSource, {
+ quotedPost: { ...quotedPost, actor: quotedAuthor.actor },
+ });
+
+ assert.equal(updated.id, created.id);
+ assert.match(updated.contentHtml, /Updated note body/);
+
+ const quotedAfterUpdate = await tx.query.postTable.findFirst({
+ where: { id: quotedPost.id },
+ });
+ assert.ok(quotedAfterUpdate != null);
+ assert.equal(quotedAfterUpdate.quotesCount, 1);
+ });
+});
diff --git a/models/post.ts b/models/post.ts
index 669eb82f..addcb25b 100644
--- a/models/post.ts
+++ b/models/post.ts
@@ -1572,9 +1572,6 @@ export async function deletePost(
for (const reply of replies) {
await deletePost(fedCtx, { ...reply, replyTarget: post });
}
- if (post.replyTarget != null) {
- await updateRepliesCount(db, post.replyTarget, -1);
- }
// Get posts quoting this post before deleting
const quotingPosts = await db.query.postTable.findMany({
where: {
@@ -1591,15 +1588,37 @@ export async function deletePost(
),
).returning();
- const originalPosts = await db.query.postTable.findMany({
- where: {
- OR: [
- ...post.replyTargetId == null ? [] : [{ id: post.replyTargetId }],
- ...post.sharedPostId == null ? [] : [{ id: post.sharedPostId }],
- ...post.quotedPostId == null ? [] : [{ id: post.quotedPostId }],
- ],
- },
- });
+ const originalPostIds = [
+ post.replyTargetId,
+ post.sharedPostId,
+ post.quotedPostId,
+ ].filter((id): id is Uuid => id != null);
+ const originalPosts = originalPostIds.length < 1
+ ? []
+ : await db.query.postTable.findMany({
+ where: {
+ OR: originalPostIds.map((id) => ({ id })),
+ },
+ });
+
+ if (post.replyTargetId != null) {
+ const replyTarget = originalPosts.find((p) => p.id === post.replyTargetId);
+ if (replyTarget != null) {
+ await updateRepliesCount(db, replyTarget, -1);
+ }
+ }
+ if (post.sharedPostId != null) {
+ const sharedPost = originalPosts.find((p) => p.id === post.sharedPostId);
+ if (sharedPost != null) {
+ await updateSharesCount(db, sharedPost, -1);
+ }
+ }
+ if (post.quotedPostId != null) {
+ const quotedPost = originalPosts.find((p) => p.id === post.quotedPostId);
+ if (quotedPost != null) {
+ await updateQuotesCount(db, quotedPost, -1);
+ }
+ }
// When a quoted post is deleted, update the quotes count of the original posts
for (const quotingPost of quotingPosts) {
diff --git a/models/reaction.test.ts b/models/reaction.test.ts
new file mode 100644
index 00000000..f3cea9aa
--- /dev/null
+++ b/models/reaction.test.ts
@@ -0,0 +1,178 @@
+import { assert } from "@std/assert/assert";
+import { assertEquals } from "@std/assert/equals";
+import { react, undoReaction } from "./reaction.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ insertNotePost,
+ withRollback,
+} from "../test/postgres.ts";
+
+Deno.test({
+ name:
+ "react() stores a reaction, updates counts, and notifies the post author",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const author = await insertAccountWithActor(tx, {
+ username: "authorreact",
+ name: "Author React",
+ email: "authorreact@example.com",
+ });
+ const reactor = await insertAccountWithActor(tx, {
+ username: "reactor",
+ name: "Reactor",
+ email: "reactor@example.com",
+ });
+ const { post } = await insertNotePost(tx, {
+ account: author.account,
+ content: "React to me",
+ });
+
+ const created = await react(fedCtx, reactor.account, {
+ ...post,
+ actor: author.actor,
+ }, "🎉");
+
+ assert(created != null);
+
+ const storedPost = await tx.query.postTable.findFirst({
+ where: { id: post.id },
+ });
+ assert(storedPost != null);
+ assertEquals(storedPost.reactionsCounts, { "🎉": 1 });
+
+ const reactions = await tx.query.reactionTable.findMany({
+ where: { postId: post.id },
+ });
+ assertEquals(reactions.length, 1);
+ assertEquals(reactions[0].actorId, reactor.actor.id);
+ assertEquals(reactions[0].emoji, "🎉");
+
+ const notification = await tx.query.notificationTable.findFirst({
+ where: {
+ accountId: author.account.id,
+ type: "react",
+ postId: post.id,
+ },
+ });
+ assert(notification != null);
+ assertEquals(notification.actorIds, [reactor.actor.id]);
+ assertEquals(notification.emoji, "🎉");
+ });
+ },
+});
+
+Deno.test({
+ name: "react() ignores duplicate standard emoji reactions",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const author = await insertAccountWithActor(tx, {
+ username: "dupauthor",
+ name: "Dup Author",
+ email: "dupauthor@example.com",
+ });
+ const reactor = await insertAccountWithActor(tx, {
+ username: "dupreactor",
+ name: "Dup Reactor",
+ email: "dupreactor@example.com",
+ });
+ const { post } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Duplicate reaction target",
+ });
+
+ await react(
+ fedCtx,
+ reactor.account,
+ { ...post, actor: author.actor },
+ "❤️",
+ );
+ const duplicate = await react(
+ fedCtx,
+ reactor.account,
+ { ...post, actor: author.actor },
+ "❤️",
+ );
+
+ assertEquals(duplicate, undefined);
+
+ const reactions = await tx.query.reactionTable.findMany({
+ where: { postId: post.id },
+ });
+ assertEquals(reactions.length, 1);
+
+ const storedPost = await tx.query.postTable.findFirst({
+ where: { id: post.id },
+ });
+ assert(storedPost != null);
+ assertEquals(storedPost.reactionsCounts, { "❤️": 1 });
+ });
+ },
+});
+
+Deno.test({
+ name: "undoReaction() removes the reaction counts and notification",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const author = await insertAccountWithActor(tx, {
+ username: "undoauthor",
+ name: "Undo Author",
+ email: "undoauthor@example.com",
+ });
+ const reactor = await insertAccountWithActor(tx, {
+ username: "undoreactor",
+ name: "Undo Reactor",
+ email: "undoreactor@example.com",
+ });
+ const { post } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Undo reaction target",
+ });
+
+ await react(
+ fedCtx,
+ reactor.account,
+ { ...post, actor: author.actor },
+ "👀",
+ );
+
+ const removed = await undoReaction(
+ fedCtx,
+ reactor.account,
+ { ...post, actor: author.actor },
+ "👀",
+ );
+
+ assert(removed != null);
+
+ const reactions = await tx.query.reactionTable.findMany({
+ where: { postId: post.id },
+ });
+ assertEquals(reactions, []);
+
+ const storedPost = await tx.query.postTable.findFirst({
+ where: { id: post.id },
+ });
+ assert(storedPost != null);
+ assertEquals(storedPost.reactionsCounts, {});
+
+ const notification = await tx.query.notificationTable.findFirst({
+ where: {
+ accountId: author.account.id,
+ type: "react",
+ postId: post.id,
+ },
+ });
+ assertEquals(notification, undefined);
+ });
+ },
+});
diff --git a/models/session.test.ts b/models/session.test.ts
new file mode 100644
index 00000000..1533c527
--- /dev/null
+++ b/models/session.test.ts
@@ -0,0 +1,35 @@
+import { assert } from "@std/assert/assert";
+import { assertEquals } from "@std/assert/equals";
+import { createSession, deleteSession, getSession } from "./session.ts";
+import { createTestKv } from "../test/postgres.ts";
+
+Deno.test({
+ name: "sessions round-trip through Keyv",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ const { kv } = createTestKv();
+ const createdAt = new Date("2026-04-15T00:00:00.000Z");
+
+ const session = await createSession(kv, {
+ id: "019d9162-ffff-7fff-8fff-ffffffffffff",
+ accountId: "019d9162-eeee-7eee-8eee-eeeeeeeeeeee",
+ userAgent: "session-test",
+ ipAddress: "127.0.0.1",
+ created: createdAt,
+ });
+
+ assertEquals(session.id, "019d9162-ffff-7fff-8fff-ffffffffffff");
+ assertEquals(session.accountId, "019d9162-eeee-7eee-8eee-eeeeeeeeeeee");
+ assertEquals(session.userAgent, "session-test");
+ assertEquals(session.ipAddress, "127.0.0.1");
+ assertEquals(session.created, createdAt);
+
+ const stored = await getSession(kv, session.id);
+ assert(stored != null);
+ assertEquals(stored, session);
+
+ assertEquals(await deleteSession(kv, session.id), true);
+ assertEquals(await getSession(kv, session.id), undefined);
+ },
+});
diff --git a/models/signin.test.ts b/models/signin.test.ts
new file mode 100644
index 00000000..dce663b1
--- /dev/null
+++ b/models/signin.test.ts
@@ -0,0 +1,33 @@
+import { assert } from "@std/assert/assert";
+import { assertEquals } from "@std/assert/equals";
+import {
+ createSigninToken,
+ deleteSigninToken,
+ getSigninToken,
+} from "./signin.ts";
+import { createTestKv } from "../test/postgres.ts";
+
+Deno.test({
+ name: "signin tokens round-trip through Keyv",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ const { kv } = createTestKv();
+ const accountId = "019d9162-ffff-7fff-8fff-ffffffffffff";
+
+ const token = await createSigninToken(kv, accountId);
+ const stored = await getSigninToken(kv, token.token);
+
+ assert(stored != null);
+ assertEquals(stored.accountId, accountId);
+ assertEquals(stored.token, token.token);
+ assertEquals(stored.code, token.code);
+ assert(stored.created instanceof Date);
+ assertEquals(/^[0-9A-Z]{6}$/.test(stored.code), true);
+
+ await deleteSigninToken(kv, token.token);
+
+ const deleted = await getSigninToken(kv, token.token);
+ assertEquals(deleted, undefined);
+ },
+});
diff --git a/models/signup.test.ts b/models/signup.test.ts
new file mode 100644
index 00000000..14dfc6d6
--- /dev/null
+++ b/models/signup.test.ts
@@ -0,0 +1,86 @@
+import { assert } from "@std/assert/assert";
+import { assertEquals } from "@std/assert/equals";
+import {
+ createAccount,
+ createSignupToken,
+ deleteSignupToken,
+ getSignupToken,
+} from "./signup.ts";
+import {
+ createTestKv,
+ insertAccountWithActor,
+ withRollback,
+} from "../test/postgres.ts";
+
+Deno.test({
+ name: "signup tokens round-trip through Keyv",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ const { kv } = createTestKv();
+
+ const token = await createSignupToken(kv, "candidate@example.com", {
+ inviterId: "019d9162-ffff-7fff-8fff-ffffffffffff",
+ });
+ const stored = await getSignupToken(kv, token.token);
+
+ assert(stored != null);
+ assertEquals(stored.email, "candidate@example.com");
+ assertEquals(stored.token, token.token);
+ assertEquals(stored.code, token.code);
+ assertEquals(stored.inviterId, "019d9162-ffff-7fff-8fff-ffffffffffff");
+ assert(stored.created instanceof Date);
+
+ await deleteSignupToken(kv, token.token);
+
+ const deleted = await getSignupToken(kv, token.token);
+ assertEquals(deleted, undefined);
+ },
+});
+
+Deno.test({
+ name: "createAccount() stores inviter and verified email",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const { kv } = createTestKv();
+ const inviter = await insertAccountWithActor(tx, {
+ username: "signupmodelinviter",
+ name: "Signup Model Inviter",
+ email: "signupmodelinviter@example.com",
+ });
+ const token = await createSignupToken(kv, "modelsignup@example.com", {
+ inviterId: inviter.account.id,
+ });
+
+ const account = await createAccount(tx, token, {
+ username: "modelsignup",
+ name: "Model Signup",
+ bio: "Created from signup model test",
+ leftInvitations: 0,
+ });
+
+ assert(account != null);
+ assertEquals(account.username, "modelsignup");
+ assertEquals(account.name, "Model Signup");
+ assertEquals(account.bio, "Created from signup model test");
+ assertEquals(account.inviterId, inviter.account.id);
+ assertEquals(account.emails.length, 1);
+ assertEquals(account.emails[0].email, "modelsignup@example.com");
+ assertEquals(account.emails[0].accountId, account.id);
+ assertEquals(account.emails[0].public, false);
+ assert(account.emails[0].verified != null);
+
+ const storedAccount = await tx.query.accountTable.findFirst({
+ where: { id: account.id },
+ with: { emails: true },
+ });
+ assert(storedAccount != null);
+ assertEquals(storedAccount.inviterId, inviter.account.id);
+ assertEquals(storedAccount.emails.map((email) => email.email), [
+ "modelsignup@example.com",
+ ]);
+ });
+ },
+});
diff --git a/models/timeline.query.test.ts b/models/timeline.query.test.ts
new file mode 100644
index 00000000..773b6464
--- /dev/null
+++ b/models/timeline.query.test.ts
@@ -0,0 +1,155 @@
+import { assertEquals } from "@std/assert/equals";
+import { eq } from "drizzle-orm";
+import { follow } from "./following.ts";
+import { sharePost } from "./post.ts";
+import { getPersonalTimeline, getPublicTimeline } from "./timeline.ts";
+import { postTable } from "./schema.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ insertNotePost,
+ insertRemoteActor,
+ insertRemotePost,
+ withRollback,
+} from "../test/postgres.ts";
+
+Deno.test({
+ name: "getPublicTimeline() applies local and withoutShares filters",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const localAuthor = await insertAccountWithActor(tx, {
+ username: "publiclocalauthor",
+ name: "Public Local Author",
+ email: "publiclocalauthor@example.com",
+ });
+ const sharer = await insertAccountWithActor(tx, {
+ username: "publicsharer",
+ name: "Public Sharer",
+ email: "publicsharer@example.com",
+ });
+ const { post: localPost } = await insertNotePost(tx, {
+ account: localAuthor.account,
+ content: "Local public post",
+ });
+ const remoteActor = await insertRemoteActor(tx, {
+ username: "publicremote",
+ name: "Public Remote",
+ host: "remote.example",
+ });
+ const remotePost = await insertRemotePost(tx, {
+ actorId: remoteActor.id,
+ contentHtml: "Remote public post
",
+ });
+ const share = await sharePost(fedCtx, sharer.account, {
+ ...remotePost,
+ actor: remoteActor,
+ });
+
+ await tx.update(postTable)
+ .set({
+ published: new Date("2026-04-15T00:00:01.000Z"),
+ updated: new Date("2026-04-15T00:00:01.000Z"),
+ })
+ .where(eq(postTable.id, localPost.id));
+ await tx.update(postTable)
+ .set({
+ published: new Date("2026-04-15T00:00:02.000Z"),
+ updated: new Date("2026-04-15T00:00:02.000Z"),
+ })
+ .where(eq(postTable.id, remotePost.id));
+ await tx.update(postTable)
+ .set({
+ published: new Date("2026-04-15T00:00:03.000Z"),
+ updated: new Date("2026-04-15T00:00:03.000Z"),
+ })
+ .where(eq(postTable.id, share.id));
+
+ const all = await getPublicTimeline(tx, { window: 10 });
+ assertEquals(all.map((entry) => entry.post.id), [
+ share.id,
+ remotePost.id,
+ localPost.id,
+ ]);
+
+ const localOnly = await getPublicTimeline(tx, {
+ local: true,
+ window: 10,
+ });
+ assertEquals(localOnly.map((entry) => entry.post.id), [
+ share.id,
+ localPost.id,
+ ]);
+
+ const localWithoutShares = await getPublicTimeline(tx, {
+ local: true,
+ withoutShares: true,
+ window: 10,
+ });
+ assertEquals(localWithoutShares.map((entry) => entry.post.id), [
+ localPost.id,
+ ]);
+ });
+ },
+});
+
+Deno.test({
+ name: "getPersonalTimeline() hides pure shares when withoutShares is enabled",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const viewer = await insertAccountWithActor(tx, {
+ username: "personaltimelineviewer",
+ name: "Personal Timeline Viewer",
+ email: "personaltimelineviewer@example.com",
+ });
+ const sharer = await insertAccountWithActor(tx, {
+ username: "personaltimelinesharer",
+ name: "Personal Timeline Sharer",
+ email: "personaltimelinesharer@example.com",
+ });
+ const remoteActor = await insertRemoteActor(tx, {
+ username: "personaltimelineremote",
+ name: "Personal Timeline Remote",
+ host: "remote.timeline.example",
+ });
+ const remotePost = await insertRemotePost(tx, {
+ actorId: remoteActor.id,
+ contentHtml: "Remote timeline post
",
+ });
+
+ await follow(fedCtx, viewer.account, sharer.actor);
+ const share = await sharePost(fedCtx, sharer.account, {
+ ...remotePost,
+ actor: remoteActor,
+ });
+
+ await tx.update(postTable)
+ .set({
+ published: new Date("2026-04-15T00:00:04.000Z"),
+ updated: new Date("2026-04-15T00:00:04.000Z"),
+ })
+ .where(eq(postTable.id, share.id));
+
+ const timeline = await getPersonalTimeline(tx, {
+ currentAccount: viewer.account,
+ window: 10,
+ });
+ assertEquals(timeline.length, 1);
+ assertEquals(timeline[0].post.id, remotePost.id);
+ assertEquals(timeline[0].lastSharer?.id, sharer.actor.id);
+ assertEquals(timeline[0].sharersCount, 1);
+
+ const withoutShares = await getPersonalTimeline(tx, {
+ currentAccount: viewer.account,
+ withoutShares: true,
+ window: 10,
+ });
+ assertEquals(withoutShares, []);
+ });
+ },
+});
diff --git a/models/timeline.test.ts b/models/timeline.test.ts
new file mode 100644
index 00000000..056b7ef4
--- /dev/null
+++ b/models/timeline.test.ts
@@ -0,0 +1,151 @@
+import { assert } from "@std/assert/assert";
+import { assertEquals } from "@std/assert/equals";
+import { eq } from "drizzle-orm";
+import { follow } from "./following.ts";
+import { sharePost } from "./post.ts";
+import { postTable } from "./schema.ts";
+import { addPostToTimeline, removeFromTimeline } from "./timeline.ts";
+import {
+ createFedCtx,
+ insertAccountWithActor,
+ insertMention,
+ insertNotePost,
+ insertRemoteActor,
+ insertRemotePost,
+ withRollback,
+} from "../test/postgres.ts";
+
+Deno.test({
+ name:
+ "addPostToTimeline() delivers direct posts only to the author and mentions",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const author = await insertAccountWithActor(tx, {
+ username: "timelinedirectauthor",
+ name: "Timeline Direct Author",
+ email: "timelinedirectauthor@example.com",
+ });
+ const follower = await insertAccountWithActor(tx, {
+ username: "timelinedirectfollower",
+ name: "Timeline Direct Follower",
+ email: "timelinedirectfollower@example.com",
+ });
+ const mentioned = await insertAccountWithActor(tx, {
+ username: "timelinedirectmention",
+ name: "Timeline Direct Mention",
+ email: "timelinedirectmention@example.com",
+ });
+ const { post } = await insertNotePost(tx, {
+ account: author.account,
+ content: "Direct post",
+ visibility: "direct",
+ });
+
+ await follow(fedCtx, follower.account, author.actor);
+ await insertMention(tx, { postId: post.id, actorId: mentioned.actor.id });
+
+ await addPostToTimeline(tx, post);
+
+ const timelineItems = await tx.query.timelineItemTable.findMany({
+ where: { postId: post.id },
+ orderBy: { accountId: "asc" },
+ });
+
+ assertEquals(
+ timelineItems.map((item) => item.accountId).sort(),
+ [author.account.id, mentioned.account.id].sort(),
+ );
+ assert(
+ !timelineItems.some((item) => item.accountId === follower.account.id),
+ );
+ });
+ },
+});
+
+Deno.test({
+ name: "removeFromTimeline() falls back to the previous sharer",
+ sanitizeOps: false,
+ sanitizeResources: false,
+ async fn() {
+ await withRollback(async (tx) => {
+ const fedCtx = createFedCtx(tx);
+ const remoteAuthorSuffix = crypto.randomUUID().replaceAll("-", "").slice(
+ 0,
+ 8,
+ );
+ const viewer = await insertAccountWithActor(tx, {
+ username: "timelineviewer",
+ name: "Timeline Viewer",
+ email: "timelineviewer@example.com",
+ });
+ const firstSharer = await insertAccountWithActor(tx, {
+ username: "timelinefirstsharer",
+ name: "Timeline First Sharer",
+ email: "timelinefirstsharer@example.com",
+ });
+ const secondSharer = await insertAccountWithActor(tx, {
+ username: "timelinesecondsharer",
+ name: "Timeline Second Sharer",
+ email: "timelinesecondsharer@example.com",
+ });
+ const remoteActor = await insertRemoteActor(tx, {
+ username: `remoteauthor${remoteAuthorSuffix}`,
+ name: "Remote Author",
+ host: "remote.example",
+ });
+ const originalPost = await insertRemotePost(tx, {
+ actorId: remoteActor.id,
+ contentHtml: "Shared timeline post
",
+ });
+
+ await follow(fedCtx, viewer.account, firstSharer.actor);
+ await follow(fedCtx, viewer.account, secondSharer.actor);
+
+ const firstShare = await sharePost(fedCtx, firstSharer.account, {
+ ...originalPost,
+ actor: remoteActor,
+ });
+ const secondShare = await sharePost(fedCtx, secondSharer.account, {
+ ...originalPost,
+ actor: remoteActor,
+ });
+
+ const firstPublished = new Date("2026-04-15T00:00:01.000Z");
+ const secondPublished = new Date("2026-04-15T00:00:02.000Z");
+
+ await tx.update(postTable)
+ .set({ published: firstPublished, updated: firstPublished })
+ .where(eq(postTable.id, firstShare.id));
+ await tx.update(postTable)
+ .set({ published: secondPublished, updated: secondPublished })
+ .where(eq(postTable.id, secondShare.id));
+
+ const before = await tx.query.timelineItemTable.findFirst({
+ where: {
+ accountId: viewer.account.id,
+ postId: originalPost.id,
+ },
+ });
+ assert(before != null);
+ assertEquals(before.lastSharerId, secondSharer.actor.id);
+ assertEquals(before.sharersCount, 2);
+
+ await tx.delete(postTable).where(eq(postTable.id, secondShare.id));
+ await removeFromTimeline(tx, secondShare);
+
+ const after = await tx.query.timelineItemTable.findFirst({
+ where: {
+ accountId: viewer.account.id,
+ postId: originalPost.id,
+ },
+ });
+ assert(after != null);
+ assertEquals(after.lastSharerId, firstSharer.actor.id);
+ assertEquals(after.sharersCount, 1);
+ assertEquals(after.appended.toISOString(), firstPublished.toISOString());
+ });
+ },
+});
diff --git a/test/postgres.ts b/test/postgres.ts
new file mode 100644
index 00000000..431d70ee
--- /dev/null
+++ b/test/postgres.ts
@@ -0,0 +1,454 @@
+import { assert } from "@std/assert/assert";
+import type { RequestContext } from "@fedify/fedify";
+import type { ContextData } from "@hackerspub/models/context";
+import type { Transaction } from "@hackerspub/models/db";
+import type { Transport } from "@upyo/core";
+import {
+ accountEmailTable,
+ accountTable,
+ actorTable,
+ instanceTable,
+ mentionTable,
+ type NewPost,
+ noteSourceTable,
+ postTable,
+} from "@hackerspub/models/schema";
+import { generateUuidV7 } from "@hackerspub/models/uuid";
+import type { Uuid } from "@hackerspub/models/uuid";
+import { db } from "../graphql/db.ts";
+import type { UserContext } from "../graphql/builder.ts";
+
+export type AuthenticatedAccount = NonNullable;
+
+export interface TestKv {
+ readonly store: Map;
+ readonly kv: UserContext["kv"];
+}
+
+export interface TestEmailTransport {
+ readonly messages: unknown[];
+ readonly transport: UserContext["email"];
+}
+
+export async function withRollback(
+ run: (tx: Transaction) => Promise,
+): Promise {
+ let rolledBack = false;
+
+ try {
+ await db.transaction(async (tx) => {
+ await run(tx);
+ rolledBack = true;
+ tx.rollback();
+ });
+ } catch (error) {
+ if (!rolledBack) throw error;
+ }
+}
+
+export async function seedLocalInstance(
+ tx: Transaction,
+ host = "localhost",
+): Promise {
+ await tx.insert(instanceTable).values({
+ host,
+ software: "hackerspub",
+ softwareVersion: "test",
+ }).onConflictDoNothing();
+}
+
+export async function insertAccountWithActor(
+ tx: Transaction,
+ values: {
+ username: string;
+ name: string;
+ email: string;
+ iri?: string;
+ inboxUrl?: string;
+ host?: string;
+ },
+): Promise<{
+ account: AuthenticatedAccount;
+ actor: AuthenticatedAccount["actor"];
+}> {
+ const accountId = generateUuidV7();
+ const actorId = generateUuidV7();
+ const timestamp = new Date("2026-04-15T00:00:00.000Z");
+ const host = values.host ?? "localhost";
+
+ await seedLocalInstance(tx, host);
+
+ await tx.insert(accountTable).values({
+ id: accountId,
+ username: values.username,
+ name: values.name,
+ bio: "",
+ leftInvitations: 0,
+ created: timestamp,
+ updated: timestamp,
+ });
+
+ await tx.insert(accountEmailTable).values({
+ email: values.email,
+ accountId,
+ public: false,
+ verified: timestamp,
+ created: timestamp,
+ });
+
+ await tx.insert(actorTable).values({
+ id: actorId,
+ iri: values.iri ?? `http://${host}/@${values.username}`,
+ type: "Person",
+ username: values.username,
+ instanceHost: host,
+ handleHost: host,
+ accountId,
+ name: values.name,
+ inboxUrl: values.inboxUrl ?? `http://${host}/@${values.username}/inbox`,
+ sharedInboxUrl: `http://${host}/inbox`,
+ created: timestamp,
+ updated: timestamp,
+ published: timestamp,
+ });
+
+ const account = await tx.query.accountTable.findFirst({
+ where: { id: accountId },
+ with: {
+ actor: true,
+ emails: true,
+ links: true,
+ },
+ });
+
+ assert(account != null);
+
+ return {
+ account: account as AuthenticatedAccount,
+ actor: account.actor,
+ };
+}
+
+export async function insertRemoteActor(
+ tx: Transaction,
+ values: {
+ username: string;
+ name: string;
+ host: string;
+ iri?: string;
+ inboxUrl?: string;
+ },
+) {
+ const actorId = generateUuidV7();
+ const timestamp = new Date("2026-04-15T00:00:00.000Z");
+
+ await seedLocalInstance(tx, values.host);
+
+ await tx.insert(actorTable).values({
+ id: actorId,
+ iri: values.iri ?? `https://${values.host}/users/${values.username}`,
+ type: "Person",
+ username: values.username,
+ instanceHost: values.host,
+ handleHost: values.host,
+ name: values.name,
+ inboxUrl: values.inboxUrl ??
+ `https://${values.host}/users/${values.username}/inbox`,
+ sharedInboxUrl: `https://${values.host}/inbox`,
+ created: timestamp,
+ updated: timestamp,
+ published: timestamp,
+ });
+
+ const actor = await tx.query.actorTable.findFirst({ where: { id: actorId } });
+ assert(actor != null);
+ return actor;
+}
+
+export async function insertNotePost(
+ tx: Transaction,
+ values: {
+ account: AuthenticatedAccount;
+ actorId?: string;
+ content?: string;
+ contentHtml?: string;
+ language?: string;
+ visibility?: "public" | "unlisted" | "followers" | "direct" | "none";
+ reactionsCounts?: Record;
+ replyTargetId?: Uuid;
+ quotedPostId?: Uuid;
+ sharedPostId?: Uuid;
+ published?: Date;
+ updated?: Date;
+ },
+) {
+ const timestamp = values.published ?? new Date("2026-04-15T00:00:00.000Z");
+ const updated = values.updated ?? timestamp;
+ const noteSourceId = generateUuidV7();
+ const noteId = generateUuidV7();
+
+ await tx.insert(noteSourceTable).values({
+ id: noteSourceId,
+ accountId: values.account.id,
+ visibility: values.visibility ?? "public",
+ content: values.content ?? "Hello world",
+ language: values.language ?? "en",
+ published: timestamp,
+ updated,
+ });
+
+ const postValues: NewPost = {
+ id: noteId,
+ iri: `http://localhost/objects/${noteId}`,
+ type: "Note",
+ visibility: values.visibility ?? "public",
+ actorId: (values.actorId ?? values.account.actor.id) as Uuid,
+ noteSourceId,
+ sharedPostId: values.sharedPostId,
+ replyTargetId: values.replyTargetId,
+ quotedPostId: values.quotedPostId,
+ contentHtml: values.contentHtml ??
+ `${values.content ?? "Hello world"}
`,
+ language: values.language ?? "en",
+ reactionsCounts: values.reactionsCounts ?? {},
+ url: `http://localhost/@${values.account.username}/${noteSourceId}`,
+ published: timestamp,
+ updated,
+ };
+
+ await tx.insert(postTable).values(postValues);
+
+ const post = await tx.query.postTable.findFirst({
+ where: { id: noteId },
+ });
+ assert(post != null);
+
+ return { noteSourceId, post };
+}
+
+export async function insertRemotePost(
+ tx: Transaction,
+ values: {
+ actorId: Uuid;
+ contentHtml?: string;
+ language?: string;
+ visibility?: "public" | "unlisted" | "followers" | "direct" | "none";
+ published?: Date;
+ updated?: Date;
+ replyTargetId?: Uuid;
+ quotedPostId?: Uuid;
+ sharedPostId?: Uuid;
+ },
+) {
+ const timestamp = values.published ?? new Date("2026-04-15T00:00:00.000Z");
+ const updated = values.updated ?? timestamp;
+ const postId = generateUuidV7();
+
+ const postValues: NewPost = {
+ id: postId,
+ iri: `https://remote.example/objects/${postId}`,
+ type: "Note",
+ visibility: values.visibility ?? "public",
+ actorId: values.actorId,
+ sharedPostId: values.sharedPostId,
+ replyTargetId: values.replyTargetId,
+ quotedPostId: values.quotedPostId,
+ contentHtml: values.contentHtml ?? "Remote post
",
+ language: values.language ?? "en",
+ reactionsCounts: {},
+ published: timestamp,
+ updated,
+ };
+
+ await tx.insert(postTable).values(postValues);
+
+ const post = await tx.query.postTable.findFirst({ where: { id: postId } });
+ assert(post != null);
+ return post;
+}
+
+export async function insertMention(
+ tx: Transaction,
+ values: { postId: Uuid; actorId: Uuid },
+) {
+ await tx.insert(mentionTable).values(values);
+}
+
+export function createTestKv(): TestKv {
+ const store = new Map();
+
+ return {
+ store,
+ kv: {
+ get(key: string) {
+ return Promise.resolve(store.get(key));
+ },
+ getMany(keys: string[]) {
+ return Promise.resolve(keys.map((key) => store.get(key)));
+ },
+ set(key: string, value: unknown) {
+ store.set(key, value);
+ return Promise.resolve(true);
+ },
+ delete(key: string) {
+ return Promise.resolve(store.delete(key));
+ },
+ } as UserContext["kv"],
+ };
+}
+
+export function toPlainJson(value: T): T {
+ return JSON.parse(JSON.stringify(value));
+}
+
+export function createTestDisk(): ContextData["disk"] {
+ return {
+ getUrl(key: string) {
+ return Promise.resolve(`http://localhost/media/${key}`);
+ },
+ put() {
+ return Promise.resolve(undefined);
+ },
+ delete() {
+ return Promise.resolve(undefined);
+ },
+ } as unknown as ContextData["disk"];
+}
+
+let mockFetchLock: Promise = Promise.resolve();
+
+export async function withMockFetch(
+ handler: typeof globalThis.fetch,
+ run: () => Promise,
+): Promise {
+ const previousLock = mockFetchLock;
+ let releaseLock!: () => void;
+ mockFetchLock = new Promise((resolve) => {
+ releaseLock = resolve;
+ });
+
+ await previousLock;
+
+ const original = globalThis.fetch;
+ globalThis.fetch = handler;
+ try {
+ return await run();
+ } finally {
+ globalThis.fetch = original;
+ releaseLock();
+ }
+}
+
+export function createTestEmailTransport(): TestEmailTransport {
+ const messages: unknown[] = [];
+
+ const receipt = { successful: true, errorMessages: [] };
+
+ return {
+ messages,
+ transport: {
+ send(message: unknown) {
+ messages.push(message);
+ return Promise.resolve(receipt);
+ },
+ async *sendMany(batch: Iterable) {
+ for (const message of batch) {
+ messages.push(message);
+ yield receipt;
+ }
+ },
+ } as unknown as Transport,
+ };
+}
+
+export function createFedCtx(
+ tx: Transaction,
+ options: { kv?: UserContext["kv"] } = {},
+): RequestContext {
+ const kv = options.kv ?? createTestKv().kv;
+
+ return {
+ host: "localhost",
+ origin: "http://localhost/",
+ canonicalOrigin: "http://localhost/",
+ data: {
+ db: tx,
+ kv: kv as unknown as ContextData["kv"],
+ disk: createTestDisk(),
+ models: {} as ContextData["models"],
+ },
+ getActorUri(identifier: string) {
+ return new URL(`/actors/${identifier}`, "http://localhost/");
+ },
+ getInboxUri(identifier?: string) {
+ return identifier == null
+ ? new URL("/inbox", "http://localhost/")
+ : new URL(`/actors/${identifier}/inbox`, "http://localhost/");
+ },
+ getFollowersUri(identifier: string) {
+ return new URL(`/actors/${identifier}/followers`, "http://localhost/");
+ },
+ getFollowingUri(identifier: string) {
+ return new URL(`/actors/${identifier}/following`, "http://localhost/");
+ },
+ getObjectUri(_type: unknown, values: Record) {
+ if ("id" in values) {
+ return new URL(`/objects/${values.id}`, "http://localhost/");
+ }
+ return new URL(
+ `/objects/${Object.values(values).join("/")}`,
+ "http://localhost/",
+ );
+ },
+ sendActivity() {
+ return Promise.resolve(undefined);
+ },
+ } as unknown as RequestContext;
+}
+
+export function makeUserContext(
+ tx: Transaction,
+ account: AuthenticatedAccount,
+ overrides: Partial = {},
+): UserContext {
+ const kv = overrides.kv ?? createTestKv().kv;
+ const email = overrides.email ?? createTestEmailTransport().transport;
+ const fedCtx = overrides.fedCtx ?? createFedCtx(tx, { kv });
+
+ return {
+ db: tx,
+ kv,
+ disk: createTestDisk() as UserContext["disk"],
+ email,
+ fedCtx,
+ request: new Request("http://localhost/graphql"),
+ session: {
+ id: generateUuidV7(),
+ accountId: account.id,
+ created: new Date("2026-04-15T00:00:00.000Z"),
+ },
+ account,
+ ...overrides,
+ };
+}
+
+export function makeGuestContext(
+ tx: Transaction,
+ overrides: Partial = {},
+): UserContext {
+ const kv = overrides.kv ?? createTestKv().kv;
+ const email = overrides.email ?? createTestEmailTransport().transport;
+ const fedCtx = overrides.fedCtx ?? createFedCtx(tx, { kv });
+
+ return {
+ db: tx,
+ kv,
+ disk: createTestDisk() as UserContext["disk"],
+ email,
+ fedCtx,
+ request: new Request("http://localhost/graphql"),
+ session: undefined,
+ account: undefined,
+ ...overrides,
+ };
+}