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, + }; +}