From e80199816db58740f3f13281d2330db5bcac95cb Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Tue, 16 Dec 2025 11:11:08 -0500 Subject: [PATCH] test: add mock/context util --- deno.json | 6 +- deno.lock | 196 ++++++++- tests/main_test.ts | 90 ++--- tests/util.ts | 966 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1187 insertions(+), 71 deletions(-) create mode 100644 tests/util.ts diff --git a/deno.json b/deno.json index 5a462ef..5db0a6f 100644 --- a/deno.json +++ b/deno.json @@ -31,11 +31,13 @@ "jose": "npm:jose@^6.1.3", "mongoose": "npm:mongoose@^8.20.2", "multiformats": "npm:multiformats@^13.4.1", - "p-queue": "npm:p-queue@^8.1.1" + "p-queue": "npm:p-queue@^8.1.1", + "mongodb-memory-server-core": "npm:mongodb-memory-server-core@^11.0.0" }, "test": { "permissions": { - "env": true + "env": true, + "read": true } } } diff --git a/deno.lock b/deno.lock index a9d6dd2..e95a314 100644 --- a/deno.lock +++ b/deno.lock @@ -39,6 +39,7 @@ "npm:dotenv@^17.2.3": "17.2.3", "npm:jose@^6.1.3": "6.1.3", "npm:lodash@*": "4.17.21", + "npm:mongodb-memory-server-core@11": "11.0.0", "npm:mongoose@^8.20.2": "8.20.2", "npm:multiformats@^13.4.1": "13.4.1", "npm:p-queue@^8.1.1": "8.1.1", @@ -276,16 +277,49 @@ "@types/webidl-conversions" ] }, + "@types/whatwg-url@13.0.0": { + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "dependencies": [ + "@types/webidl-conversions" + ] + }, + "agent-base@7.1.4": { + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" + }, + "async-mutex@0.5.0": { + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "dependencies": [ + "tslib" + ] + }, "await-lock@2.2.2": { "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" }, + "b4a@1.7.3": { + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==" + }, + "bare-events@2.8.2": { + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==" + }, "bson@6.10.4": { "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==" }, + "bson@7.0.0": { + "integrity": "sha512-Kwc6Wh4lQ5OmkqqKhYGKIuELXl+EPYSCObVE6bWsp1T/cGkOCBN0I8wF/T44BiuhHyNi1mmKVPXk60d41xZ7kw==" + }, + "buffer-crc32@0.2.13": { + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==" + }, + "camelcase@6.3.0": { + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==" + }, "cborg@4.3.2": { "integrity": "sha512-l+QzebEAG0vb09YKkaOrMi2zmm80UNjmbvocMIeW5hO7JOXWdrQ/H49yOKfYX0MBgrj/KWgatBnEgRXyNyKD+A==", "bin": true }, + "commondir@1.0.1": { + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==" + }, "debug@4.4.3": { "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dependencies": [ @@ -298,9 +332,43 @@ "eventemitter3@5.0.1": { "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" }, + "events-universal@1.0.1": { + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dependencies": [ + "bare-events" + ] + }, + "fast-fifo@1.3.2": { + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" + }, + "find-cache-dir@3.3.2": { + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dependencies": [ + "commondir", + "make-dir", + "pkg-dir" + ] + }, + "find-up@4.1.0": { + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": [ + "locate-path", + "path-exists" + ] + }, + "follow-redirects@1.15.11": { + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==" + }, "graphemer@1.4.0": { "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" }, + "https-proxy-agent@7.0.6": { + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dependencies": [ + "agent-base", + "debug" + ] + }, "iso-datestring-validator@2.2.2": { "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==" }, @@ -310,33 +378,77 @@ "kareem@2.6.3": { "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==" }, + "locate-path@5.0.0": { + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": [ + "p-locate" + ] + }, "lodash@4.17.21": { "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "make-dir@3.1.0": { + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dependencies": [ + "semver@6.3.1" + ] + }, "memory-pager@1.5.0": { "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" }, "mongodb-connection-string-url@3.0.2": { "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", "dependencies": [ - "@types/whatwg-url", + "@types/whatwg-url@11.0.5", "whatwg-url" ] }, + "mongodb-connection-string-url@7.0.0": { + "integrity": "sha512-irhhjRVLE20hbkRl4zpAYLnDMM+zIZnp0IDB9akAFFUZp/3XdOfwwddc7y6cNvF2WCEtfTYRwYbIfYa2kVY0og==", + "dependencies": [ + "@types/whatwg-url@13.0.0", + "whatwg-url" + ] + }, + "mongodb-memory-server-core@11.0.0": { + "integrity": "sha512-qg1XWrmX+7IL6SyRKiknHEcNYJomnvw14WxjEkRaAXh3IsJ/DPwEykQqAW5vzGedeSSETZtyWwxyXHQLPqnA/Q==", + "dependencies": [ + "async-mutex", + "camelcase", + "debug", + "find-cache-dir", + "follow-redirects", + "https-proxy-agent", + "mongodb@7.0.0", + "new-find-package-json", + "semver@7.7.3", + "tar-stream", + "tslib", + "yauzl" + ] + }, "mongodb@6.20.0": { "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", "dependencies": [ "@mongodb-js/saslprep", - "bson", - "mongodb-connection-string-url" + "bson@6.10.4", + "mongodb-connection-string-url@3.0.2" + ] + }, + "mongodb@7.0.0": { + "integrity": "sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg==", + "dependencies": [ + "@mongodb-js/saslprep", + "bson@7.0.0", + "mongodb-connection-string-url@7.0.0" ] }, "mongoose@8.20.2": { "integrity": "sha512-U0TPupnqBOAI3p9H9qdShX8/nJUBylliRcHFKuhbewEkM7Y0qc9BbrQR9h4q6+1easoZqej7cq2Ee36AZ0gMzQ==", "dependencies": [ - "bson", + "bson@6.10.4", "kareem", - "mongodb", + "mongodb@6.20.0", "mpath", "mquery", "ms", @@ -361,6 +473,24 @@ "multiformats@9.9.0": { "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==" }, + "new-find-package-json@2.0.0": { + "integrity": "sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==", + "dependencies": [ + "debug" + ] + }, + "p-limit@2.3.0": { + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": [ + "p-try" + ] + }, + "p-locate@4.1.0": { + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": [ + "p-limit" + ] + }, "p-queue@8.1.1": { "integrity": "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==", "dependencies": [ @@ -371,12 +501,35 @@ "p-timeout@6.1.4": { "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==" }, + "p-try@2.2.0": { + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" + }, + "path-exists@4.0.0": { + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" + }, + "pend@1.2.0": { + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" + }, + "pkg-dir@4.2.0": { + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dependencies": [ + "find-up" + ] + }, "punycode@2.3.1": { "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, "rate-limiter-flexible@9.0.0": { "integrity": "sha512-Dz+NKRx+V9WVLY6QJv2soo/eN+cYL/+/1XggE/tgnGuA+D/Q1em0hWVp6AZd8lfhzZ6+2wrk7TEwYN+x9AsHeg==" }, + "semver@6.3.1": { + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": true + }, + "semver@7.7.3": { + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "bin": true + }, "sift@17.1.3": { "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==" }, @@ -386,6 +539,28 @@ "memory-pager" ] }, + "streamx@2.23.0": { + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dependencies": [ + "events-universal", + "fast-fifo", + "text-decoder" + ] + }, + "tar-stream@3.1.7": { + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dependencies": [ + "b4a", + "fast-fifo", + "streamx" + ] + }, + "text-decoder@1.2.3": { + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dependencies": [ + "b4a" + ] + }, "tlds@1.260.0": { "integrity": "sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==", "bin": true @@ -396,6 +571,9 @@ "punycode" ] }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "uint8arrays@3.0.0": { "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", "dependencies": [ @@ -415,6 +593,13 @@ "webidl-conversions" ] }, + "yauzl@3.2.0": { + "integrity": "sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==", + "dependencies": [ + "buffer-crc32", + "pend" + ] + }, "zod@3.25.76": { "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" } @@ -439,6 +624,7 @@ "npm:@atproto/api@~0.16.11", "npm:dotenv@^17.2.3", "npm:jose@^6.1.3", + "npm:mongodb-memory-server-core@11", "npm:mongoose@^8.20.2", "npm:multiformats@^13.4.1", "npm:p-queue@^8.1.1" diff --git a/tests/main_test.ts b/tests/main_test.ts index 8da45a5..a3a03db 100644 --- a/tests/main_test.ts +++ b/tests/main_test.ts @@ -1,69 +1,9 @@ import { assertEquals } from "@std/assert"; import { assertMatch } from "@std/assert/match"; -import { createApp } from "../main.ts"; -import { AppContext } from "../context.ts"; -import { Database } from "../data-plane/db/index.ts"; -import { createAuthVerifier } from "../auth-verifier.ts"; -import { getLogger } from "@logtape/logtape"; -import { DataPlane } from "../data-plane/index.ts"; -import { Hydrator } from "../hydration/index.ts"; -import { Views } from "../views/index.ts"; -import { IdResolver } from "@atp/identity"; -import { ServerConfig } from "../config.ts"; - -const cfg = new ServerConfig({ - relayUrl: "http://localhost:8080", - serverDid: "did:web:localhost", - modServiceDid: "did:web:test", - adminPasswords: ["test"], - privateKey: - "5676df35fd3a185a1771a43536635ad90057e0c0d1fd91436344bb50ce23a460", - publicUrl: "http://localhost:4000", - alternateAudienceDids: [], - bigThreadUris: new Set(["did:web:test"]), - maxThreadParents: 10, -}); - -// Create a mock context for testing without database -function createMockContext(): AppContext { - const appLogger = getLogger(["appview"]); - const idResolver = new IdResolver(); - const serviceDid = "did:web:test"; - - // Create mock database that doesn't actually connect - const mockDb = { - connect: () => Promise.resolve(), - disconnect: () => Promise.resolve(), - models: {}, - getCursorState: () => Promise.resolve(null), - saveCursorState: () => Promise.resolve(), - } as unknown as Database; - - const dataplane = new DataPlane(mockDb, idResolver); - const hydrator = new Hydrator(dataplane); - const views = new Views(cfg); - const authVerifier = createAuthVerifier(dataplane, { - ownDid: serviceDid, - alternateAudienceDids: [], - modServiceDid: "did:web:test", - adminPasses: ["test"], - }); - - return { - db: mockDb, - dataplane, - hydrator, - views, - logger: appLogger, - idResolver, - cfg, - authVerifier, - }; -} +import { createMockApp, createMockContext } from "./util.ts"; Deno.test("Basic App Creation", async () => { - const ctx = createMockContext(); - const app = createApp(ctx); + const app = createMockApp(); const res = await app.request("/", { headers: { @@ -75,8 +15,7 @@ Deno.test("Basic App Creation", async () => { }); Deno.test("Well Known Endpoint", async () => { - const ctx = createMockContext(); - const app = createApp(ctx); + const app = createMockApp(); const res = await app.request("/.well-known/did.json", { headers: { @@ -107,3 +46,26 @@ Deno.test("Well Known Endpoint", async () => { ), ); }); + +Deno.test("Mock Context Creation", () => { + const ctx = createMockContext(); + + assertEquals(typeof ctx.db, "object"); + assertEquals(typeof ctx.dataplane, "object"); + assertEquals(typeof ctx.hydrator, "object"); + assertEquals(typeof ctx.views, "object"); + assertEquals(typeof ctx.authVerifier, "function"); // AuthVerifier is a callable function + assertEquals(typeof ctx.logger, "object"); + assertEquals(typeof ctx.idResolver, "object"); + assertEquals(ctx.cfg.serverDid, "did:web:localhost"); +}); + +Deno.test("Mock Context with Config Overrides", () => { + const ctx = createMockContext({ + serverDid: "did:web:custom.test", + adminPasswords: ["custom-password"], + }); + + assertEquals(ctx.cfg.serverDid, "did:web:custom.test"); + assertEquals(ctx.cfg.adminPasswords, ["custom-password"]); +}); diff --git a/tests/util.ts b/tests/util.ts new file mode 100644 index 0000000..fa82e16 --- /dev/null +++ b/tests/util.ts @@ -0,0 +1,966 @@ +import { MongoMemoryServer } from "mongodb-memory-server-core"; +import mongoose, { Connection } from "mongoose"; +import * as models from "../data-plane/db/models.ts"; +import { Hono } from "hono"; +import { createApp } from "../main.ts"; +import { AppContext, AppEnv } from "../context.ts"; +import { Database } from "../data-plane/db/index.ts"; +import { createAuthVerifier } from "../auth-verifier.ts"; +import { getLogger } from "@logtape/logtape"; +import { DataPlane } from "../data-plane/index.ts"; +import { Hydrator } from "../hydration/index.ts"; +import { Views } from "../views/index.ts"; +import { IdResolver } from "@atp/identity"; +import { ServerConfig, ServerConfigValues } from "../config.ts"; + +// Configure mongodb-memory-server to use a specific download directory +// This prevents issues with empty paths when running with restricted permissions +const downloadDir = Deno.env.get("MONGOMS_DOWNLOAD_DIR") || + `${Deno.env.get("HOME")}/.cache/mongodb-binaries`; +Deno.env.set("MONGOMS_DOWNLOAD_DIR", downloadDir); + +// ============================================================================ +// Test Data Options +// ============================================================================ + +export interface TestDataOptions { + actors?: boolean; + profiles?: boolean; + posts?: boolean; + replies?: boolean; + stories?: boolean; + likes?: boolean; + reposts?: boolean; + follows?: boolean; + blocks?: boolean; + audio?: boolean; + generators?: boolean; + preferences?: boolean; + records?: boolean; + actorSync?: boolean; +} + +// ============================================================================ +// Test Database Types +// ============================================================================ + +export interface TestDatabase { + mongoServer: MongoMemoryServer; + connection: Connection; + models: models.DatabaseModels; + cleanup: () => Promise; +} + +// ============================================================================ +// Test App Types +// ============================================================================ + +export interface TestApp { + app: Hono; + ctx: AppContext; + cleanup: () => Promise; +} + +// ============================================================================ +// Default Test Config +// ============================================================================ + +const DEFAULT_TEST_CONFIG: ServerConfigValues = { + relayUrl: "http://localhost:8080", + serverDid: "did:web:localhost", + modServiceDid: "did:web:test", + adminPasswords: ["test"], + privateKey: + "5676df35fd3a185a1771a43536635ad90057e0c0d1fd91436344bb50ce23a460", + publicUrl: "http://localhost:4000", + alternateAudienceDids: [], + bigThreadUris: new Set(["did:web:test"]), + maxThreadParents: 10, +}; + +// ============================================================================ +// Test Users +// ============================================================================ + +export const TEST_USERS = [ + { did: "did:plc:testuser1", handle: "alice.test" }, + { did: "did:plc:testuser2", handle: "bob.test" }, + { did: "did:plc:testuser3", handle: "charlie.test" }, + { did: "did:plc:testuser4", handle: "diana.test" }, +] as const; + +// ============================================================================ +// Database Setup Functions +// ============================================================================ + +/** + * Creates an in-memory MongoDB instance with fake sample data for testing + * @param options - Options to control which data categories to create + * @returns TestDatabase instance with connection and cleanup method + */ +export async function createTestDatabase( + options: TestDataOptions = {}, +): Promise { + // Default to creating all data types + const opts = { + actors: true, + profiles: true, + posts: true, + replies: true, + stories: true, + likes: true, + reposts: true, + follows: true, + blocks: true, + audio: true, + generators: true, + preferences: true, + records: true, + actorSync: true, + ...options, + }; + + // Start MongoDB Memory Server + const mongoServer = await MongoMemoryServer.create(); + const uri = mongoServer.getUri(); + + // Create connection + const connection = mongoose.createConnection(uri, { + autoIndex: true, + autoCreate: true, + }); + + // Initialize models + const dbModels: models.DatabaseModels = { + Record: connection.model( + "Record", + models.recordSchema, + ), + DuplicateRecord: connection.model( + "DuplicateRecord", + models.duplicateRecordSchema, + ), + Like: connection.model("Like", models.likeSchema), + Post: connection.model("Post", models.postSchema), + Reply: connection.model("Reply", models.replySchema), + Story: connection.model("Story", models.storySchema), + Follow: connection.model( + "Follow", + models.followSchema, + ), + Block: connection.model("Block", models.blockSchema), + Profile: connection.model( + "Profile", + models.profileSchema, + ), + Audio: connection.model("Audio", models.audioSchema), + Repost: connection.model( + "Repost", + models.repostSchema, + ), + Generator: connection.model( + "Generator", + models.generatorSchema, + ), + Takedown: connection.model( + "Takedown", + models.takedownSchema, + ), + RepoTakedown: connection.model( + "RepoTakedown", + models.repoTakedownSchema, + ), + BlobTakedown: connection.model( + "BlobTakedown", + models.blobTakedownSchema, + ), + Actor: connection.model("Actor", models.actorSchema), + ActorSync: connection.model( + "ActorSync", + models.actorSyncSchema, + ), + Preference: connection.model( + "Preference", + models.preferenceSchema, + ), + CursorState: connection.model( + "CursorState", + models.cursorStateSchema, + ), + }; + + // Seed data + await seedTestData(dbModels, opts); + + const cleanup = async () => { + try { + await connection.close(); + } catch (_err) { + // Ignore close errors + } + await mongoServer.stop(); + }; + + return { + mongoServer, + connection, + models: dbModels, + cleanup, + }; +} + +/** + * Helper function to get a test database URI without creating the full database + * Useful for testing Database class initialization + */ +export async function getTestDatabaseUri(): Promise<{ + uri: string; + cleanup: () => Promise; +}> { + const mongoServer = await MongoMemoryServer.create(); + const uri = mongoServer.getUri(); + + const cleanup = async () => { + await mongoServer.stop(); + }; + + return { uri, cleanup }; +} + +// ============================================================================ +// App Setup Functions +// ============================================================================ + +/** + * Creates a mock context for testing without a real database connection + * @param configOverrides - Optional config values to override defaults + * @returns AppContext instance with mock database + */ +export function createMockContext( + configOverrides: Partial = {}, +): AppContext { + const cfg = new ServerConfig({ ...DEFAULT_TEST_CONFIG, ...configOverrides }); + const appLogger = getLogger(["appview"]); + const idResolver = new IdResolver(); + + // Create mock database that doesn't actually connect + const mockDb = { + connect: () => Promise.resolve(), + disconnect: () => Promise.resolve(), + models: {}, + getCursorState: () => Promise.resolve(null), + saveCursorState: () => Promise.resolve(), + idResolver, + } as unknown as Database; + + const dataplane = new DataPlane(mockDb, idResolver); + const hydrator = new Hydrator(dataplane); + const views = new Views(cfg); + const authVerifier = createAuthVerifier(dataplane, { + ownDid: cfg.serverDid, + alternateAudienceDids: cfg.alternateAudienceDids, + modServiceDid: cfg.modServiceDid, + adminPasses: cfg.adminPasswords, + }); + + return { + db: mockDb, + dataplane, + hydrator, + views, + logger: appLogger, + idResolver, + cfg, + authVerifier, + }; +} + +/** + * Creates a full test context with an in-memory MongoDB database + * @param options - Options to control test data seeding + * @param configOverrides - Optional config values to override defaults + * @returns AppContext instance with real database and cleanup function + */ +export async function createTestContext( + options: TestDataOptions = {}, + configOverrides: Partial = {}, +): Promise<{ ctx: AppContext; cleanup: () => Promise }> { + const testDb = await createTestDatabase(options); + const cfg = new ServerConfig({ ...DEFAULT_TEST_CONFIG, ...configOverrides }); + const appLogger = getLogger(["appview"]); + const idResolver = new IdResolver(); + + // Create a wrapper Database object that uses the test connection and models + const db = { + connection: testDb.connection, + models: testDb.models, + idResolver, + logger: getLogger(["appview", "database"]), + connect: () => Promise.resolve(), + disconnect: async () => { + await testDb.cleanup(); + }, + getCursorState: async () => { + const cursorState = await testDb.models.CursorState.findOne({ + identifier: "last_processed_cursor", + }); + return cursorState?.cursorValue || null; + }, + saveCursorState: async (cursorPosition: number) => { + await testDb.models.CursorState.findOneAndUpdate( + { identifier: "last_processed_cursor" }, + { + cursorValue: cursorPosition, + updatedAt: new Date(), + }, + { upsert: true }, + ); + }, + resolveHandle: async (handle: string) => { + return await idResolver.handle.resolve(handle); + }, + resolveDid: async (did: string) => { + const data = await idResolver.did.resolveAtprotoData(did); + return { did: data.did, handle: data.handle }; + }, + getIdentityByDid: async ({ did }: { did: string }) => { + const doc = await idResolver.did.resolve(did); + if (!doc) throw new Error("DID not found"); + return { did }; + }, + } as unknown as Database; + + const dataplane = new DataPlane(db, idResolver); + const hydrator = new Hydrator(dataplane); + const views = new Views(cfg); + const authVerifier = createAuthVerifier(dataplane, { + ownDid: cfg.serverDid, + alternateAudienceDids: cfg.alternateAudienceDids, + modServiceDid: cfg.modServiceDid, + adminPasses: cfg.adminPasswords, + }); + + const ctx: AppContext = { + db, + dataplane, + hydrator, + views, + logger: appLogger, + idResolver, + cfg, + authVerifier, + }; + + const cleanup = async () => { + await testDb.cleanup(); + }; + + return { ctx, cleanup }; +} + +/** + * Creates a full test app with an in-memory MongoDB database + * This is the primary function to use for integration tests + * @param options - Options to control test data seeding + * @param configOverrides - Optional config values to override defaults + * @returns TestApp instance with app, context, and cleanup function + */ +export async function createTestApp( + options: TestDataOptions = {}, + configOverrides: Partial = {}, +): Promise { + const { ctx, cleanup } = await createTestContext(options, configOverrides); + const app = createApp(ctx); + + return { + app, + ctx, + cleanup, + }; +} + +/** + * Creates a test app with a mock database (no real MongoDB connection) + * Useful for testing routes that don't require database access + * @param configOverrides - Optional config values to override defaults + * @returns Hono app instance with mock context + */ +export function createMockApp( + configOverrides: Partial = {}, +): Hono { + const ctx = createMockContext(configOverrides); + return createApp(ctx); +} + +// ============================================================================ +// Test Data Seeding +// ============================================================================ + +/** + * Seeds the test database with fake sample data + */ +async function seedTestData( + models: models.DatabaseModels, + options: Required, +): Promise { + const now = new Date().toISOString(); + const baseCid = "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq"; + + // Create Actors + if (options.actors) { + for (const user of TEST_USERS) { + await models.Actor.create({ + did: user.did, + handle: user.handle, + indexedAt: now, + takedownRef: "", + upstreamStatus: "active", + keys: JSON.stringify({ signing: "key123" }), + services: JSON.stringify({ pds: "https://pds.test" }), + }); + } + } + + // Create ActorSync records + if (options.actorSync) { + for (const user of TEST_USERS) { + await models.ActorSync.create({ + did: user.did, + commitCid: `${baseCid}commit${user.did.slice(-1)}`, + repoRev: "rev123", + }); + } + } + + // Create Profiles + if (options.profiles) { + await models.Profile.create({ + uri: `at://${TEST_USERS[0].did}/app.bsky.actor.profile/self`, + cid: `${baseCid}profile1`, + authorDid: TEST_USERS[0].did, + createdAt: now, + indexedAt: now, + displayName: "Alice", + description: "Software engineer and coffee enthusiast", + avatar: { + $type: "blob", + ref: { $link: `${baseCid}avatar1` }, + mimeType: "image/jpeg", + size: 50000, + }, + banner: { + $type: "blob", + ref: { $link: `${baseCid}banner1` }, + mimeType: "image/jpeg", + size: 100000, + }, + labels: [], + pinnedPost: "", + postsCount: 5, + followersCount: 100, + followsCount: 50, + }); + + await models.Profile.create({ + uri: `at://${TEST_USERS[1].did}/app.bsky.actor.profile/self`, + cid: `${baseCid}profile2`, + authorDid: TEST_USERS[1].did, + createdAt: now, + indexedAt: now, + displayName: "Bob", + description: "Music lover and tech geek", + labels: [], + pinnedPost: "", + postsCount: 3, + followersCount: 75, + followsCount: 80, + }); + + await models.Profile.create({ + uri: `at://${TEST_USERS[2].did}/app.bsky.actor.profile/self`, + cid: `${baseCid}profile3`, + authorDid: TEST_USERS[2].did, + createdAt: now, + indexedAt: now, + displayName: "Charlie", + description: "Adventure seeker", + labels: [], + pinnedPost: "", + postsCount: 10, + followersCount: 200, + followsCount: 150, + }); + } + + // Create Audio + const audioUris: string[] = []; + if (options.audio) { + const audio1 = await models.Audio.create({ + uri: `at://${TEST_USERS[1].did}/app.sprk.audio/audio1`, + cid: `${baseCid}audio1`, + authorDid: TEST_USERS[1].did, + createdAt: now, + indexedAt: now, + sound: { + $type: "blob", + ref: { $link: `${baseCid}sound1` }, + mimeType: "audio/mpeg", + size: 1000000, + }, + origin: "original", + title: "Chill Beats", + details: "Relaxing music for coding", + labels: [], + useCount: 10, + }); + audioUris.push(audio1.uri); + + const audio2 = await models.Audio.create({ + uri: `at://${TEST_USERS[1].did}/app.sprk.audio/audio2`, + cid: `${baseCid}audio2`, + authorDid: TEST_USERS[1].did, + createdAt: now, + indexedAt: now, + sound: { + $type: "blob", + ref: { $link: `${baseCid}sound2` }, + mimeType: "audio/mpeg", + size: 800000, + }, + origin: "original", + title: "Summer Vibes", + details: "Upbeat summer track", + labels: [], + useCount: 5, + }); + audioUris.push(audio2.uri); + } + + // Create Posts + const postUris: string[] = []; + if (options.posts) { + const post1 = await models.Post.create({ + uri: `at://${TEST_USERS[0].did}/app.sprk.post/post1`, + cid: `${baseCid}post1`, + authorDid: TEST_USERS[0].did, + createdAt: now, + indexedAt: now, + caption: { + text: "Just posted my first video! #excited", + facets: [ + { + index: { byteStart: 30, byteEnd: 38 }, + features: [{ + $type: "app.bsky.richtext.facet#tag", + tag: "excited", + }], + }, + ], + }, + media: { + $type: "app.sprk.post#videoMedia", + video: { + $type: "blob", + ref: { $link: `${baseCid}video1` }, + alt: "My first video", + aspectRatio: { width: 1080, height: 1920 }, + mimeType: "video/mp4", + size: 5000000, + }, + }, + sound: audioUris.length > 0 + ? { uri: audioUris[0], cid: `${baseCid}audio1` } + : undefined, + langs: ["en"], + labels: [], + tags: ["excited"], + likeCount: 25, + replyCount: 3, + repostCount: 5, + }); + postUris.push(post1.uri); + + const post2 = await models.Post.create({ + uri: `at://${TEST_USERS[2].did}/app.sprk.post/post2`, + cid: `${baseCid}post2`, + authorDid: TEST_USERS[2].did, + createdAt: now, + indexedAt: now, + caption: { + text: "Check out these amazing photos from my hike!", + facets: [], + }, + media: { + $type: "app.sprk.post#imageMedia", + images: [ + { + $type: "blob", + ref: { $link: `${baseCid}img1` }, + alt: "Mountain view", + aspectRatio: { width: 4, height: 3 }, + mimeType: "image/jpeg", + size: 200000, + }, + { + $type: "blob", + ref: { $link: `${baseCid}img2` }, + alt: "Trail path", + aspectRatio: { width: 4, height: 3 }, + mimeType: "image/jpeg", + size: 180000, + }, + ], + }, + langs: ["en"], + labels: [], + tags: [], + likeCount: 50, + replyCount: 8, + repostCount: 12, + }); + postUris.push(post2.uri); + + const post3 = await models.Post.create({ + uri: `at://${TEST_USERS[1].did}/app.sprk.post/post3`, + cid: `${baseCid}post3`, + authorDid: TEST_USERS[1].did, + createdAt: now, + indexedAt: now, + caption: { + text: "Simple text post without media", + facets: [], + }, + langs: ["en"], + labels: [], + tags: [], + likeCount: 10, + replyCount: 2, + repostCount: 1, + }); + postUris.push(post3.uri); + } + + // Create Replies + if (options.replies && postUris.length > 0) { + await models.Reply.create({ + uri: `at://${TEST_USERS[1].did}/app.sprk.reply/reply1`, + cid: `${baseCid}reply1`, + authorDid: TEST_USERS[1].did, + createdAt: now, + indexedAt: now, + text: "This is awesome!", + facets: [], + reply: { + root: { uri: postUris[0], cid: `${baseCid}post1` }, + parent: { uri: postUris[0], cid: `${baseCid}post1` }, + }, + langs: ["en"], + labels: [], + likeCount: 5, + replyCount: 0, + }); + + await models.Reply.create({ + uri: `at://${TEST_USERS[2].did}/app.sprk.reply/reply2`, + cid: `${baseCid}reply2`, + authorDid: TEST_USERS[2].did, + createdAt: now, + indexedAt: now, + text: "Great content!", + facets: [], + reply: { + root: { uri: postUris[0], cid: `${baseCid}post1` }, + parent: { uri: postUris[0], cid: `${baseCid}post1` }, + }, + media: { + $type: "app.sprk.reply#imageMedia", + images: [ + { + $type: "blob", + ref: { $link: `${baseCid}replyimg1` }, + alt: "Reply image", + aspectRatio: { width: 1, height: 1 }, + mimeType: "image/jpeg", + size: 100000, + }, + ], + }, + langs: ["en"], + labels: [], + likeCount: 3, + replyCount: 0, + }); + } + + // Create Stories + if (options.stories) { + await models.Story.create({ + uri: `at://${TEST_USERS[0].did}/app.sprk.story/story1`, + cid: `${baseCid}story1`, + authorDid: TEST_USERS[0].did, + createdAt: now, + indexedAt: now, + media: { + $type: "app.sprk.story#videoMedia", + video: { + $type: "blob", + ref: { $link: `${baseCid}storyvid1` }, + alt: "Story video", + aspectRatio: { width: 1080, height: 1920 }, + mimeType: "video/mp4", + size: 3000000, + }, + }, + sound: audioUris.length > 0 + ? { uri: audioUris[1], cid: `${baseCid}audio2` } + : undefined, + labels: [], + }); + + await models.Story.create({ + uri: `at://${TEST_USERS[2].did}/app.sprk.story/story2`, + cid: `${baseCid}story2`, + authorDid: TEST_USERS[2].did, + createdAt: now, + indexedAt: now, + media: { + $type: "app.sprk.story#imageMedia", + image: { + $type: "blob", + ref: { $link: `${baseCid}storyimg1` }, + alt: "Story image", + aspectRatio: { width: 1080, height: 1920 }, + mimeType: "image/jpeg", + size: 250000, + }, + }, + labels: [], + }); + } + + // Create Likes + if (options.likes && postUris.length > 0) { + await models.Like.create({ + uri: `at://${TEST_USERS[1].did}/app.bsky.feed.like/like1`, + cid: `${baseCid}like1`, + authorDid: TEST_USERS[1].did, + createdAt: now, + indexedAt: now, + subject: postUris[0], + subjectCid: `${baseCid}post1`, + }); + + await models.Like.create({ + uri: `at://${TEST_USERS[2].did}/app.bsky.feed.like/like2`, + cid: `${baseCid}like2`, + authorDid: TEST_USERS[2].did, + createdAt: now, + indexedAt: now, + subject: postUris[0], + subjectCid: `${baseCid}post1`, + }); + + await models.Like.create({ + uri: `at://${TEST_USERS[0].did}/app.bsky.feed.like/like3`, + cid: `${baseCid}like3`, + authorDid: TEST_USERS[0].did, + createdAt: now, + indexedAt: now, + subject: postUris[1], + subjectCid: `${baseCid}post2`, + }); + } + + // Create Reposts + if (options.reposts && postUris.length > 0) { + await models.Repost.create({ + uri: `at://${TEST_USERS[1].did}/app.bsky.feed.repost/repost1`, + cid: `${baseCid}repost1`, + authorDid: TEST_USERS[1].did, + createdAt: now, + indexedAt: now, + subject: postUris[0], + subjectCid: `${baseCid}post1`, + }); + + await models.Repost.create({ + uri: `at://${TEST_USERS[3].did}/app.bsky.feed.repost/repost2`, + cid: `${baseCid}repost2`, + authorDid: TEST_USERS[3].did, + createdAt: now, + indexedAt: now, + subject: postUris[1], + subjectCid: `${baseCid}post2`, + }); + } + + // Create Follows + if (options.follows) { + // Alice follows Bob + await models.Follow.create({ + uri: `at://${TEST_USERS[0].did}/app.bsky.graph.follow/follow1`, + cid: `${baseCid}follow1`, + authorDid: TEST_USERS[0].did, + createdAt: now, + indexedAt: now, + subject: TEST_USERS[1].did, + }); + + // Bob follows Alice + await models.Follow.create({ + uri: `at://${TEST_USERS[1].did}/app.bsky.graph.follow/follow2`, + cid: `${baseCid}follow2`, + authorDid: TEST_USERS[1].did, + createdAt: now, + indexedAt: now, + subject: TEST_USERS[0].did, + }); + + // Charlie follows Alice + await models.Follow.create({ + uri: `at://${TEST_USERS[2].did}/app.bsky.graph.follow/follow3`, + cid: `${baseCid}follow3`, + authorDid: TEST_USERS[2].did, + createdAt: now, + indexedAt: now, + subject: TEST_USERS[0].did, + }); + + // Alice follows Charlie + await models.Follow.create({ + uri: `at://${TEST_USERS[0].did}/app.bsky.graph.follow/follow4`, + cid: `${baseCid}follow4`, + authorDid: TEST_USERS[0].did, + createdAt: now, + indexedAt: now, + subject: TEST_USERS[2].did, + }); + } + + // Create Blocks + if (options.blocks) { + await models.Block.create({ + uri: `at://${TEST_USERS[0].did}/app.bsky.graph.block/block1`, + cid: `${baseCid}block1`, + authorDid: TEST_USERS[0].did, + createdAt: now, + indexedAt: now, + subject: TEST_USERS[3].did, + }); + } + + // Create Generators + if (options.generators) { + await models.Generator.create({ + uri: `at://${TEST_USERS[0].did}/app.bsky.feed.generator/gen1`, + cid: `${baseCid}gen1`, + authorDid: TEST_USERS[0].did, + createdAt: now, + indexedAt: now, + displayName: "Trending Videos", + description: "Hot video content from the community", + descriptionFacets: [], + avatar: { + $type: "blob", + ref: { $link: `${baseCid}genav1` }, + mimeType: "image/png", + size: 40000, + }, + acceptsInteractions: true, + labels: [], + likeCount: 150, + }); + + await models.Generator.create({ + uri: `at://${TEST_USERS[2].did}/app.bsky.feed.generator/gen2`, + cid: `${baseCid}gen2`, + authorDid: TEST_USERS[2].did, + createdAt: now, + indexedAt: now, + displayName: "Nature & Adventure", + description: "Outdoor content and adventure videos", + descriptionFacets: [], + acceptsInteractions: true, + labels: [], + likeCount: 80, + }); + } + + // Create Preferences + if (options.preferences) { + await models.Preference.create({ + userDid: TEST_USERS[0].did, + contentLabelPrefs: JSON.stringify({ + labelerDids: [], + labels: {}, + }), + savedFeeds: JSON.stringify([]), + personalDetailsPref: JSON.stringify({}), + feedViewPrefs: JSON.stringify({}), + threadViewPref: JSON.stringify({}), + interestsPref: JSON.stringify({ tags: ["tech", "music"] }), + mutedWordsPref: JSON.stringify([]), + hiddenPostsPref: JSON.stringify([]), + labelersPref: JSON.stringify([]), + postInteractionSettingsPref: JSON.stringify({}), + createdAt: now, + updatedAt: now, + }); + + await models.Preference.create({ + userDid: TEST_USERS[1].did, + contentLabelPrefs: JSON.stringify({ + labelerDids: [], + labels: {}, + }), + savedFeeds: JSON.stringify([]), + personalDetailsPref: JSON.stringify({}), + feedViewPrefs: JSON.stringify({}), + threadViewPref: JSON.stringify({}), + interestsPref: JSON.stringify({ tags: ["music", "art"] }), + mutedWordsPref: JSON.stringify([]), + hiddenPostsPref: JSON.stringify([]), + labelersPref: JSON.stringify([]), + postInteractionSettingsPref: JSON.stringify({}), + createdAt: now, + updatedAt: now, + }); + } + + // Create Records + if (options.records && postUris.length > 0) { + await models.Record.create({ + uri: postUris[0], + cid: `${baseCid}post1`, + did: TEST_USERS[0].did, + collectionName: "app.sprk.post", + rkey: "post1", + createdAt: now, + indexedAt: now, + json: JSON.stringify({ + $type: "app.sprk.post", + caption: { text: "Just posted my first video! #excited" }, + createdAt: now, + }), + takenDown: false, + takedownRef: "", + }); + + await models.Record.create({ + uri: postUris[1], + cid: `${baseCid}post2`, + did: TEST_USERS[2].did, + collectionName: "app.sprk.post", + rkey: "post2", + createdAt: now, + indexedAt: now, + json: JSON.stringify({ + $type: "app.sprk.post", + caption: { text: "Check out these amazing photos from my hike!" }, + createdAt: now, + }), + takenDown: false, + takedownRef: "", + }); + } +} -- 2.51.2