From 4771fed6f0f4ca87a19eb6dbae14f415660e39ef Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Mon, 27 Oct 2025 10:21:44 -0700 Subject: [PATCH] Remove unused tests --- tests/attachments.test.ts | 128 ---- tests/authenticate.test.ts | 91 --- tests/devices.test.ts | 377 ----------- tests/init.test.ts | 199 ------ tests/invite-requests.test.ts | 455 -------------- tests/invites.test.ts | 821 ------------------------ tests/notifications-invites.test.ts | 292 --------- tests/notifications-service.test.ts | 17 - tests/notifications.test.ts | 328 ---------- tests/profiles-batch.test.ts | 196 ------ tests/profiles.test.ts | 939 ---------------------------- tests/wallet.test.ts | 139 ---- 12 files changed, 3982 deletions(-) delete mode 100644 tests/attachments.test.ts delete mode 100644 tests/authenticate.test.ts delete mode 100644 tests/devices.test.ts delete mode 100644 tests/init.test.ts delete mode 100644 tests/invite-requests.test.ts delete mode 100644 tests/invites.test.ts delete mode 100644 tests/notifications-invites.test.ts delete mode 100644 tests/notifications.test.ts delete mode 100644 tests/profiles-batch.test.ts delete mode 100644 tests/profiles.test.ts delete mode 100644 tests/wallet.test.ts diff --git a/tests/attachments.test.ts b/tests/attachments.test.ts deleted file mode 100644 index f20adc7e..00000000 --- a/tests/attachments.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import crypto from "crypto"; -import type { Server } from "http"; -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import express from "express"; -import attachmentsRouter from "@/api/v1/attachments"; -import { jsonMiddleware } from "@/middleware/json"; - -const app = express(); -app.use(jsonMiddleware); -app.use("/attachments", attachmentsRouter); - -let server: Server; - -beforeAll(() => { - // start the server on a test port - server = app.listen(3008); -}); - -afterAll(() => { - // close the server - server.close(); -}); - -// Helper function to calculate file hash -const calculateHash = (buffer: ArrayBuffer) => { - const hash = crypto.createHash("sha256"); - hash.update(Buffer.from(buffer)); - return hash.digest("hex"); -}; - -describe("/attachments API", () => { - /** - * Integration tests that perform live operations with S3. - * These tests are skipped by default as they: - * 1. Require valid S3 credentials in the environment - * 2. Make actual network requests to S3 - * 3. Create and store real files in the S3 bucket - * - * To run these tests, remove the .skip from the describe block - */ - describe.skip("integration tests", () => { - test("uploads and validates text file content", async () => { - // Get presigned URL - const response = await fetch( - "http://localhost:3008/attachments/presigned?contentType=text/plain", - ); - expect(response.status).toBe(200); - const data = (await response.json()) as { - url: string; - objectKey: string; - }; - expect(data.url).toBeDefined(); - expect(data.objectKey).toBeDefined(); - - // Create a test file content - const testContent = "Hello, this is a test file!"; - const blob = new Blob([testContent], { type: "text/plain" }); - - // Upload the file using the presigned URL - const uploadResponse = await fetch(data.url, { - method: "PUT", - body: blob, - headers: { - "Content-Type": "text/plain", - }, - }); - expect(uploadResponse.status).toBe(200); - - // Extract the public URL from the presigned URL - const fileURL = new URL(data.url); - const publicURL = fileURL.origin + fileURL.pathname; - - // Download and verify the file content - const downloadResponse = await fetch(publicURL); - expect(downloadResponse.status).toBe(200); - const downloadedContent = await downloadResponse.text(); - expect(downloadedContent).toBe(testContent); - }); - - test("uploads and validates image content", async () => { - // Create a test image (1x1 pixel PNG) - const pngImageBytes = new Uint8Array([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, - 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, - 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, - 0x0d, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0x60, 0x60, 0x60, 0x60, - 0x00, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, - 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, - ]); - - // Get presigned URL for PNG - const response = await fetch( - "http://localhost:3008/attachments/presigned?contentType=image/png", - ); - expect(response.status).toBe(200); - const data = (await response.json()) as { - url: string; - objectKey: string; - }; - - // Calculate original image hash - const originalHash = calculateHash(pngImageBytes.buffer); - - // Upload the image - const uploadResponse = await fetch(data.url, { - method: "PUT", - body: pngImageBytes, - headers: { - "Content-Type": "image/png", - }, - }); - expect(uploadResponse.status).toBe(200); - - // Get public URL and download the image - const fileURL = new URL(data.url); - const publicURL = fileURL.origin + fileURL.pathname; - const downloadResponse = await fetch(publicURL); - expect(downloadResponse.status).toBe(200); - - // Get the downloaded image as ArrayBuffer and calculate its hash - const downloadedBuffer = await downloadResponse.arrayBuffer(); - const downloadedHash = calculateHash(downloadedBuffer); - - // Compare hashes to verify the image is identical - expect(downloadedHash).toBe(originalHash); - }); - }); -}); diff --git a/tests/authenticate.test.ts b/tests/authenticate.test.ts deleted file mode 100644 index 29be869a..00000000 --- a/tests/authenticate.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { Server } from "http"; -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import express from "express"; -import * as jose from "jose"; -import { rimrafSync } from "rimraf"; -import { toHex } from "viem"; -import authenticateRouter, { - type AuthenticateResponse, -} from "@/api/v1/authenticate"; -import { errorHandlerMiddleware } from "@/middleware/errorHandler"; -import { jsonMiddleware } from "@/middleware/json"; -import { pinoMiddleware } from "@/middleware/pino"; -import { prisma } from "@/utils/prisma"; -import { createClient, createHeaders } from "./helpers"; - -// mock environment variable -process.env.FIREBASE_SERVICE_ACCOUNT = `{"projectId": "test-project-id","privateKey": "test-private-key","clientEmail": "test-client-email"}`; - -const app = express(); -app.use(jsonMiddleware); -app.use(pinoMiddleware); -app.use("/authenticate", authenticateRouter); -app.use(errorHandlerMiddleware); - -let server: Server; - -beforeAll(() => { - server = app.listen(3009); -}); - -afterAll(async () => { - await prisma.$disconnect(); - server.close(); - // clean up the test databases - rimrafSync("tests/**/*.db3*", { glob: true }); -}); - -describe("/authenticate API", () => { - test("POST /authenticate succeeds with valid headers", async () => { - const client = await createClient(); - const headers = createHeaders(client, "valid-app-check-token"); - const response = await fetch("http://localhost:3009/authenticate", { - method: "POST", - headers, - }); - - const data = (await response.json()) as AuthenticateResponse; - - expect(response.status).toBe(200); - expect(data.token).toBeDefined(); - - const decoded = await jose.jwtVerify( - data.token, - new TextEncoder().encode(process.env.JWT_SECRET), - ); - expect(decoded.payload.inboxId).toBe(client.inboxId); - }); - - test("POST /authenticate fails with invalid signature", async () => { - const client = await createClient(); - const headers = createHeaders(client, "valid-app-check-token"); - const response = await fetch("http://localhost:3009/authenticate", { - method: "POST", - headers: { - ...headers, - "X-XMTP-Signature": toHex( - client.signWithInstallationKey("invalid-signature"), - ), - }, - }); - - const data = (await response.json()) as { error: string }; - - expect(response.status).toBe(400); - expect(data.error).toBe("Invalid signature"); - }); - - test.skip("POST /authenticate fails with invalid AppCheck token", async () => { - const client = await createClient(); - const headers = createHeaders(client, "invalid-app-check-token"); - const response = await fetch("http://localhost:3009/authenticate", { - method: "POST", - headers, - }); - - const data = (await response.json()) as { error: string }; - - expect(response.status).toBe(500); - expect(data.error).toBe("Failed to create authentication token"); - }); -}); diff --git a/tests/devices.test.ts b/tests/devices.test.ts deleted file mode 100644 index 46d2c414..00000000 --- a/tests/devices.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import type { Server } from "http"; -import { DeviceOS, type Device } from "@prisma/client"; -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - test, -} from "bun:test"; -import express from "express"; -import devicesRouter from "@/api/v1/devices/devices.router"; -import type { - InitRequestBody, - InitResponse, -} from "@/api/v1/init/handlers/init"; -import initRouter from "@/api/v1/init/init.router"; -import { jsonMiddleware } from "@/middleware/json"; -import { prisma } from "@/utils/prisma"; - -// Constants used throughout tests -const AUTH_XMTP_ID = "test-xmtp-id"; - -const app = express(); -app.use(jsonMiddleware); - -// Add middleware to simulate authentication for tests -app.use((req, res, next) => { - // Set xmtpId for testing - this simulates the auth middleware - res.locals.xmtpId = AUTH_XMTP_ID; - next(); -}); - -app.use("/users", initRouter); -app.use("/devices", devicesRouter); - -let server: Server; - -beforeAll(() => { - // start the server on a test port - server = app.listen(3002); -}); - -afterAll(async () => { - // disconnect from the database - await prisma.$disconnect(); - // close the server - server.close(); -}); - -beforeEach(async () => { - // clean up the database before each test - await prisma.profile.deleteMany(); - await prisma.identitiesOnDevice.deleteMany(); - await prisma.deviceIdentity.deleteMany(); - await prisma.device.deleteMany(); -}); - -describe("/devices API", () => { - test("POST /devices creates a new device", async () => { - // Create test user first via API - const createUserBody: InitRequestBody = { - device: { - id: "test-initial-device-id", - os: DeviceOS.ios, - name: "Test Initial Device", - }, - identity: { - identityAddress: "test-turnkey-address", - xmtpId: AUTH_XMTP_ID, - }, - profile: { - name: "Test User", - username: "test-user", - description: "Test bio", - }, - }; - - const createUserResponse = await fetch("http://localhost:3002/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const _createdUser = (await createUserResponse.json()) as InitResponse; - - const response = await fetch(`http://localhost:3002/devices`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - id: "test-new-device-id", - name: "Test Device", - os: DeviceOS.ios, - pushToken: "test-push-token", - }), - }); - - const device = (await response.json()) as Device; - - expect(response.status).toBe(201); - expect(device.name).toBe("Test Device"); - expect(device.id).toBe("test-new-device-id"); - expect(device.os).toBe(DeviceOS.ios); - expect(device.pushToken).toBe("test-push-token"); - expect(device.id).toBeDefined(); - }); - - test("GET /devices/:deviceId returns 404 for non-existent device", async () => { - // Create test user first via API - const createUserBody: InitRequestBody = { - device: { - id: "test-device-id-2", - os: DeviceOS.ios, - name: "Test Initial Device", - }, - identity: { - identityAddress: "test-turnkey-address-2", - xmtpId: AUTH_XMTP_ID, - }, - profile: { - name: "Test User 2", - username: "test-user-2", - description: "Test bio 2", - }, - }; - - const createUserResponse = await fetch("http://localhost:3002/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const _createdUser = (await createUserResponse.json()) as InitResponse; - - const response = await fetch( - `http://localhost:3002/devices/nonexistent-id`, - ); - const data = (await response.json()) as { error: string }; - - expect(response.status).toBe(404); - expect(data.error).toBe( - "Device not found or not associated with this user", - ); - }); - - test("GET /devices/:deviceId returns device when exists", async () => { - // Create test user first via API - const createUserBody: InitRequestBody = { - device: { - id: "test-device-id-3", - os: DeviceOS.ios, - name: "Test Initial Device", - }, - identity: { - identityAddress: "test-turnkey-address-3", - xmtpId: AUTH_XMTP_ID, - }, - profile: { - name: "Test User 3", - username: "test-user-3", - description: "Test bio 3", - }, - }; - - const createUserResponse = await fetch("http://localhost:3002/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const _createdUser = (await createUserResponse.json()) as InitResponse; - // create a device - const createResponse = await fetch(`http://localhost:3002/devices`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - id: "test-new-device-id-3", - name: "Test Device", - os: DeviceOS.android, - pushToken: "test-push-token", - }), - }); - const createdDevice = (await createResponse.json()) as Device; - - // fetch the device - const response = await fetch( - `http://localhost:3002/devices/${createdDevice.id}`, - ); - const device = (await response.json()) as Device; - - expect(response.status).toBe(200); - expect(device.id).toBe(createdDevice.id); - expect(device.name).toBe("Test Device"); - expect(device.id).toBe("test-new-device-id-3"); - expect(device.os).toBe(DeviceOS.android); - expect(device.pushToken).toBe("test-push-token"); - }); - - test("GET /devices returns all devices for the authenticated identity", async () => { - // Create test user first via API - const createUserBody: InitRequestBody = { - device: { - id: "test-device-id-4", - os: DeviceOS.ios, - name: "Test Initial Device", - }, - identity: { - identityAddress: "test-turnkey-address-4", - xmtpId: AUTH_XMTP_ID, - }, - profile: { - name: "Test User 4", - username: "test-user-4", - description: "Test bio 4", - }, - }; - - const createUserResponse = await fetch("http://localhost:3002/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const _createdUser = (await createUserResponse.json()) as InitResponse; - // create two devices - await fetch(`http://localhost:3002/devices`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - id: "test-device-id-4-1", - name: "Device 1", - os: DeviceOS.ios, - }), - }); - await fetch(`http://localhost:3002/devices`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - id: "test-device-id-4-2", - name: "Device 2", - os: DeviceOS.android, - }), - }); - - // fetch all devices - const response = await fetch(`http://localhost:3002/devices`); - const devices = (await response.json()) as Device[]; - - expect(response.status).toBe(200); - expect(devices).toHaveLength(3); - expect(devices.map((d) => d.name)).toContain("Device 1"); - expect(devices.map((d) => d.name)).toContain("Device 2"); - expect(devices.map((d) => d.name)).toContain("Test Initial Device"); - }); - - test("PATCH /devices/:deviceId updates device", async () => { - // Create test user first via API - const createUserBody: InitRequestBody = { - device: { - id: "test-device-id-5", - os: DeviceOS.ios, - name: "Test Initial Device", - }, - identity: { - identityAddress: "test-turnkey-address-5", - xmtpId: AUTH_XMTP_ID, - }, - profile: { - name: "Test User 5", - username: "test-user-5", - description: "Test bio 5", - }, - }; - - const createUserResponse = await fetch("http://localhost:3002/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const _createdUser = (await createUserResponse.json()) as InitResponse; - // create a device - const createResponse = await fetch(`http://localhost:3002/devices`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - id: "test-device-id-5-1", - name: "Old Name", - os: DeviceOS.ios, - pushToken: "old-token", - }), - }); - const createdDevice = (await createResponse.json()) as Device; - - // update the device - const updateResponse = await fetch( - `http://localhost:3002/devices/${createdDevice.id}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: "New Name", - os: DeviceOS.android, - pushToken: "new-token", - }), - }, - ); - // Check status first before parsing JSON - expect(updateResponse.status).toBe(200); - - const updatedDevice = (await updateResponse.json()) as Device; - expect(updatedDevice.id).toBe(createdDevice.id); - expect(updatedDevice.name).toBe("New Name"); - expect(updatedDevice.os).toBe(DeviceOS.android); - expect(updatedDevice.pushToken).toBe("new-token"); - }); - - test("POST /devices validates request body", async () => { - // Create test user first via API - const createUserBody: InitRequestBody = { - device: { - id: "test-device-id-6", - os: DeviceOS.ios, - name: "Test Initial Device", - }, - identity: { - identityAddress: "test-turnkey-address-6", - xmtpId: AUTH_XMTP_ID, - }, - profile: { - name: "Test User 6", - username: "test-user-6", - description: "Test bio 6", - }, - }; - - const createUserResponse = await fetch("http://localhost:3002/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const _createdUser = (await createUserResponse.json()) as InitResponse; - const response = await fetch(`http://localhost:3002/devices`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: "Test Device", - os: "INVALID_OS", // Invalid OS value - }), - }); - const data = (await response.json()) as { error: string }; - - expect(response.status).toBe(400); - expect(data.error).toBe("Invalid request body"); - }); -}); diff --git a/tests/init.test.ts b/tests/init.test.ts deleted file mode 100644 index a765909e..00000000 --- a/tests/init.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { Server } from "http"; -import { DeviceOS } from "@prisma/client"; -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - test, -} from "bun:test"; -import express from "express"; -import type { - InitRequestBody, - InitResponse, -} from "@/api/v1/init/handlers/init"; -import initRouter from "@/api/v1/init/init.router"; -import { jsonMiddleware } from "@/middleware/json"; -import { prisma } from "@/utils/prisma"; - -const app = express(); -app.use(jsonMiddleware); -app.use("/init", initRouter); - -let server: Server; - -beforeAll(() => { - // start the server on a test port - server = app.listen(3001); -}); - -afterAll(async () => { - // disconnect from the database - await prisma.$disconnect(); - // close the server - server.close(); -}); - -beforeEach(async () => { - // clean up the database before each test - await prisma.profile.deleteMany(); - await prisma.identitiesOnDevice.deleteMany(); - await prisma.deviceIdentity.deleteMany(); - await prisma.device.deleteMany(); -}); - -describe("/init API", () => { - test("POST /init creates a new user", async () => { - const createUserBody: InitRequestBody = { - device: { - id: "test-device-id", - os: DeviceOS.ios, - name: "iPhone 14", - }, - identity: { - identityAddress: "test-turnkey-address", - xmtpId: "test-xmtp-id", - xmtpInstallationId: "test-xmtp-installation-id", - }, - profile: { - name: "Test User", - username: "test-user", - }, - }; - - const response = await fetch("http://localhost:3001/init", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - - expect(response.status).toBe(201); - - const user = (await response.json()) as InitResponse; - expect(user.device.id).toBeDefined(); - expect(user.device.os).toBe(createUserBody.device.os); - expect(user.device.name!).toBe(createUserBody.device.name!); - expect(user.identity.id).toBeDefined(); - expect(user.identity.identityAddress).toBe( - createUserBody.identity.identityAddress || null, - ); - expect(user.identity.xmtpId).toBe(createUserBody.identity.xmtpId); - expect(user.profile.name).toBe(createUserBody.profile.name ?? null); - }); - - test("POST /init can create two users with different devices", async () => { - const createUser1Body: InitRequestBody = { - device: { - id: "test-device-id", - os: DeviceOS.ios, - name: "iPhone 14", - }, - identity: { - identityAddress: "test-turnkey-address", - xmtpId: "test-xmtp-id", - xmtpInstallationId: "test-xmtp-installation-id", - }, - profile: { - name: "Test User", - username: "test-user", - }, - }; - - const response1 = await fetch("http://localhost:3001/init", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUser1Body), - }); - - expect(response1.status).toBe(201); - - const createUser2Body: InitRequestBody = { - device: { - id: "test-device-id-2", - os: DeviceOS.ios, - name: "iPhone 14", - }, - identity: { - identityAddress: "test-turnkey-address-2", - xmtpId: "test-xmtp-id-2", - xmtpInstallationId: "test-xmtp-installation-id-2", - }, - profile: { - name: "Test User 2", - username: "test-user-2", - }, - }; - - const response2 = await fetch("http://localhost:3001/init", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUser2Body), - }); - - expect(response2.status).toBe(201); - }); - - test("POST /init can create two users with same device", async () => { - const sameDeviceId = "test-device-id"; - const createUser1Body: InitRequestBody = { - device: { - id: sameDeviceId, - os: DeviceOS.ios, - name: "iPhone 14", - }, - identity: { - identityAddress: "test-turnkey-address", - xmtpId: "test-xmtp-id", - xmtpInstallationId: "test-xmtp-installation-id", - }, - profile: { - name: "Test User", - username: "test-user", - }, - }; - - const response1 = await fetch("http://localhost:3001/init", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUser1Body), - }); - - expect(response1.status).toBe(201); - - const createUser2Body: InitRequestBody = { - device: { - id: sameDeviceId, - os: DeviceOS.ios, - name: "iPhone 14", - }, - identity: { - identityAddress: "test-turnkey-address-2", - xmtpId: "test-xmtp-id-2", - xmtpInstallationId: "test-xmtp-installation-id-2", - }, - profile: { - name: "Test User 2", - username: "test-user-2", - }, - }; - - const response2 = await fetch("http://localhost:3001/init", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUser2Body), - }); - - expect(response2.status).toBe(201); - }); -}); diff --git a/tests/invite-requests.test.ts b/tests/invite-requests.test.ts deleted file mode 100644 index c045b9f3..00000000 --- a/tests/invite-requests.test.ts +++ /dev/null @@ -1,455 +0,0 @@ -import type { Server } from "http"; -import { DeviceOS } from "@prisma/client"; -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - test, -} from "bun:test"; -import express from "express"; -import type { DeleteRequestToJoinResponse } from "@/api/v1/invites/handlers/delete-request-to-join"; -import type { GetInviteRequestsResponse } from "@/api/v1/invites/handlers/get-invite-requests"; -import type { - RequestToJoinRequestBody, - RequestToJoinResponse, -} from "@/api/v1/invites/handlers/request-to-join"; -import invitesRouter from "@/api/v1/invites/invites.router"; -import { jsonMiddleware } from "@/middleware/json"; -import { pinoMiddleware } from "@/middleware/pino"; -import { prisma } from "@/utils/prisma"; - -const app = express(); -app.use(pinoMiddleware); -app.use(jsonMiddleware); - -// Mock authentication by setting the request locals with optional override -app.use((req, _res, next) => { - const overrideXmtpId = req.headers["x-test-xmtp-id"]; - _res.locals.xmtpId = - typeof overrideXmtpId === "string" - ? overrideXmtpId - : "test-xmtp-id-requester"; - _res.locals.xmtpInstallationId = "test-installation-id"; - next(); -}); - -app.use("/invites", invitesRouter); - -let server: Server; - -beforeAll(() => { - server = app.listen(3011); -}); - -afterAll(async () => { - server.close(); - await prisma.$disconnect(); -}); - -beforeEach(async () => { - // Clean up the database before each test - await prisma.inviteCodeNotificationTarget.deleteMany(); - await prisma.inviteCodeRequest.deleteMany(); - await prisma.inviteCodeUse.deleteMany(); - await prisma.inviteCode.deleteMany(); - await prisma.profile.deleteMany(); - await prisma.identitiesOnDevice.deleteMany(); - await prisma.deviceIdentity.deleteMany(); - await prisma.device.deleteMany(); -}); - -async function createTestUsers() { - // Create invite creator identity - const creatorIdentity = await prisma.deviceIdentity.create({ - data: { - xmtpId: "test-creator-xmtp-id", - identityAddress: "0x1234creator", - }, - }); - const creatorDevice = await prisma.device.create({ - data: { id: "test-device-id-creator", os: DeviceOS.ios }, - }); - await prisma.identitiesOnDevice.create({ - data: { deviceId: creatorDevice.id, identityId: creatorIdentity.id }, - }); - - // Create requester identity used in some tests - const requesterIdentity = await prisma.deviceIdentity.create({ - data: { - xmtpId: "test-xmtp-id-requester", - identityAddress: "0x1234requester", - }, - }); - const requesterDevice = await prisma.device.create({ - data: { id: "test-device-id-requester", os: DeviceOS.android }, - }); - await prisma.identitiesOnDevice.create({ - data: { deviceId: requesterDevice.id, identityId: requesterIdentity.id }, - }); - - return { creatorIdentity, requesterIdentity }; -} - -async function createTestInviteCode(args: { autoApprove?: boolean } = {}) { - await createTestUsers(); - - const creatorIdentity = await prisma.deviceIdentity.findFirst({ - where: { xmtpId: "test-creator-xmtp-id" }, - }); - - return await prisma.inviteCode.create({ - data: { - groupId: "test-group-123", - name: "Test Group Invite", - autoApprove: args.autoApprove ?? false, // Default to requiring approval - createdById: creatorIdentity!.id, - }, - }); -} - -interface ErrorResponse { - success: boolean; - message: string; -} - -describe("/invites/request API", () => { - test("server is running", async () => { - const response = await fetch("http://localhost:3011/invites"); - expect(response.status).toBeDefined(); - }); - - test("POST /invites/request creates a new join request", async () => { - const inviteCode = await createTestInviteCode(); - - const requestBody: RequestToJoinRequestBody = { - inviteId: inviteCode.id, - }; - - const response = await fetch("http://localhost:3011/invites/request", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - expect(response.status).toBe(201); - - const joinRequest = (await response.json()) as RequestToJoinResponse; - expect(joinRequest.id).toBeDefined(); - expect(joinRequest.invite.id).toBe(inviteCode.id); - expect(joinRequest.createdAt).toBeDefined(); - - // Verify the request was created in the database - const dbRequest = await prisma.inviteCodeRequest.findUnique({ - where: { id: joinRequest.id }, - }); - expect(dbRequest).toBeTruthy(); - }); - - test("POST /invites/request rejects duplicate request", async () => { - const inviteCode = await createTestInviteCode(); - const requesterIdentity = await prisma.deviceIdentity.findFirst({ - where: { xmtpId: "test-xmtp-id-requester" }, - }); - - // Create an existing request - await prisma.inviteCodeRequest.create({ - data: { - inviteCodeId: inviteCode.id, - requesterId: requesterIdentity!.id, - }, - }); - - const requestBody: RequestToJoinRequestBody = { - inviteId: inviteCode.id, - }; - - const response = await fetch("http://localhost:3011/invites/request", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - expect(response.status).toBe(409); - - const error = (await response.json()) as ErrorResponse; - expect(error.success).toBe(false); - expect(error.message).toContain("already have a request"); - }); - - test("POST /invites/request returns 404 for non-existent invite", async () => { - await createTestUsers(); - - const requestBody: RequestToJoinRequestBody = { - inviteId: "non-existent-invite-id", - }; - - const response = await fetch("http://localhost:3011/invites/request", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - expect(response.status).toBe(404); - - const error = (await response.json()) as ErrorResponse; - expect(error.success).toBe(false); - expect(error.message).toBe("Invite not found"); - }); - - test("POST /invites/request returns 404 for identity not found", async () => { - // Don't create the requester, so identity won't be found - const inviteCode = await createTestInviteCode(); - - // Override the mock authentication to use a non-existent xmtpId - const testApp = express(); - testApp.use(pinoMiddleware); - testApp.use(jsonMiddleware); - testApp.use((req, _res, next) => { - _res.locals.xmtpId = "non-existent-xmtp-id"; - next(); - }); - testApp.use("/invites", invitesRouter); - - const testServer = testApp.listen(3012); - - try { - const requestBody: RequestToJoinRequestBody = { - inviteId: inviteCode.id, - }; - - const response = await fetch("http://localhost:3012/invites/request", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - expect(response.status).toBe(404); - - const error = (await response.json()) as ErrorResponse; - expect(error.success).toBe(false); - expect(error.message).toBe("Identity not found"); - } finally { - testServer.close(); - } - }); - - test("DELETE /invites/requests/:requestId allows requester to delete and creator sees it removed", async () => { - // Arrange: create creator/requester and invite code - const inviteCode = await createTestInviteCode(); - - // Act: requester creates a join request - const createReqRes = await fetch("http://localhost:3011/invites/request", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": "test-xmtp-id-requester", - }, - body: JSON.stringify({ inviteId: inviteCode.id }), - }); - expect(createReqRes.status).toBe(201); - const joinRequest = (await createReqRes.json()) as RequestToJoinResponse; - - // Assert: creator sees 1 request - const listBeforeRes = await fetch( - "http://localhost:3011/invites/requests", - { - method: "GET", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": "test-creator-xmtp-id", - }, - }, - ); - expect(listBeforeRes.status).toBe(200); - const listBefore = - (await listBeforeRes.json()) as GetInviteRequestsResponse; - expect(listBefore.total).toBe(1); - expect(listBefore.requests[0].id).toBe(joinRequest.id); - - // Act: requester deletes their request - const deleteRes = await fetch( - `http://localhost:3011/invites/requests/${joinRequest.id}`, - { - method: "DELETE", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": "test-xmtp-id-requester", - }, - }, - ); - expect(deleteRes.status).toBe(200); - const deleted = (await deleteRes.json()) as DeleteRequestToJoinResponse; - expect(deleted.id).toBe(joinRequest.id); - expect(deleted.deleted).toBe(true); - - // Assert: creator sees 0 requests now - const listAfterRes = await fetch("http://localhost:3011/invites/requests", { - method: "GET", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": "test-creator-xmtp-id", - }, - }); - expect(listAfterRes.status).toBe(200); - const listAfter = (await listAfterRes.json()) as GetInviteRequestsResponse; - expect(listAfter.total).toBe(0); - }); - - test("DELETE /invites/requests/:requestId allows invite creator to delete", async () => { - const inviteCode = await createTestInviteCode(); - - const createReqRes = await fetch("http://localhost:3011/invites/request", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": "test-xmtp-id-requester", - }, - body: JSON.stringify({ inviteId: inviteCode.id }), - }); - expect(createReqRes.status).toBe(201); - const joinRequest = (await createReqRes.json()) as RequestToJoinResponse; - - const deleteRes = await fetch( - `http://localhost:3011/invites/requests/${joinRequest.id}`, - { - method: "DELETE", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": "test-creator-xmtp-id", - }, - }, - ); - expect(deleteRes.status).toBe(200); - const deleted = (await deleteRes.json()) as DeleteRequestToJoinResponse; - expect(deleted.id).toBe(joinRequest.id); - expect(deleted.deleted).toBe(true); - - // creator sees 0 requests now - const listAfterRes = await fetch("http://localhost:3011/invites/requests", { - method: "GET", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": "test-creator-xmtp-id", - }, - }); - expect(listAfterRes.status).toBe(200); - const listAfter = (await listAfterRes.json()) as GetInviteRequestsResponse; - expect(listAfter.total).toBe(0); - }); - - test("DELETE /invites/requests/:requestId allows notification target to delete", async () => { - const inviteCode = await createTestInviteCode(); - - // Create a notification target identity and link it to the invite - const notifier = await prisma.deviceIdentity.create({ - data: { xmtpId: "test-notifier-xmtp-id" }, - }); - await prisma.inviteCodeNotificationTarget.create({ - data: { - inviteCodeId: inviteCode.id, - deviceIdentityId: notifier.id, - }, - }); - - const createReqRes = await fetch("http://localhost:3011/invites/request", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": "test-xmtp-id-requester", - }, - body: JSON.stringify({ inviteId: inviteCode.id }), - }); - expect(createReqRes.status).toBe(201); - const joinRequest = (await createReqRes.json()) as RequestToJoinResponse; - - const deleteRes = await fetch( - `http://localhost:3011/invites/requests/${joinRequest.id}`, - { - method: "DELETE", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": "test-notifier-xmtp-id", - }, - }, - ); - expect(deleteRes.status).toBe(200); - const deleted = (await deleteRes.json()) as DeleteRequestToJoinResponse; - expect(deleted.id).toBe(joinRequest.id); - expect(deleted.deleted).toBe(true); - - const listAfterRes = await fetch("http://localhost:3011/invites/requests", { - method: "GET", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": "test-creator-xmtp-id", - }, - }); - expect(listAfterRes.status).toBe(200); - const listAfter = (await listAfterRes.json()) as GetInviteRequestsResponse; - expect(listAfter.total).toBe(0); - }); - - test("DELETE /invites/requests/:requestId by non-requester fails", async () => { - // Arrange: create creator/requester and invite code - const inviteCode = await createTestInviteCode(); - - // Ensure we also have a third identity - await prisma.deviceIdentity.create({ - data: { xmtpId: "test-user3-xmtp-id" }, - }); - - // Act: requester creates a join request - const createReqRes = await fetch("http://localhost:3011/invites/request", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": "test-xmtp-id-requester", - }, - body: JSON.stringify({ inviteId: inviteCode.id }), - }); - expect(createReqRes.status).toBe(201); - const joinRequest = (await createReqRes.json()) as RequestToJoinResponse; - - // Act: user3 attempts to delete the request - const deleteRes = await fetch( - `http://localhost:3011/invites/requests/${joinRequest.id}`, - { - method: "DELETE", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": "test-user3-xmtp-id", - }, - }, - ); - expect(deleteRes.status).toBe(404); - const error = (await deleteRes.json()) as { - success: boolean; - message: string; - }; - expect(error.success).toBe(false); - expect(error.message).toBe("Request to join not found"); - - // Assert: creator still sees 1 request - const listRes = await fetch("http://localhost:3011/invites/requests", { - method: "GET", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": "test-creator-xmtp-id", - }, - }); - expect(listRes.status).toBe(200); - const list = (await listRes.json()) as GetInviteRequestsResponse; - expect(list.total).toBe(1); - expect(list.requests[0].id).toBe(joinRequest.id); - }); -}); diff --git a/tests/invites.test.ts b/tests/invites.test.ts deleted file mode 100644 index 76de381b..00000000 --- a/tests/invites.test.ts +++ /dev/null @@ -1,821 +0,0 @@ -import type { Server } from "http"; -import { DeviceOS, InviteCodeStatus } from "@prisma/client"; -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - test, -} from "bun:test"; -import express from "express"; -import type { InitResponse } from "@/api/v1/init/handlers/init"; -import type { - CreateInviteCodeRequestBody, - CreateInviteCodeResponse, -} from "@/api/v1/invites/handlers/create-invite-code"; -import type { GetInviteDetailsResponse } from "@/api/v1/invites/handlers/get-invite-details"; -import invitesRouter from "@/api/v1/invites/invites.router"; -import { jsonMiddleware } from "@/middleware/json"; -import { pinoMiddleware } from "@/middleware/pino"; -import { prisma } from "@/utils/prisma"; - -const app = express(); -app.use(pinoMiddleware); -app.use(jsonMiddleware); - -const AUTH_USER_XMTP_ID = "test-invite-xmtp-id"; - -// Add middleware to simulate authentication for tests -app.use((req, res, next) => { - const overrideXmtpId = req.headers["x-test-xmtp-id"]; - res.locals.xmtpId = - typeof overrideXmtpId === "string" ? overrideXmtpId : AUTH_USER_XMTP_ID; - next(); -}); - -app.use("/invites", invitesRouter); - -let server: Server; - -beforeAll(() => { - // start the server on a test port - server = app.listen(3010); -}); - -afterAll(async () => { - // disconnect from the database - await prisma.$disconnect(); - // close the server - server.close(); -}); - -beforeEach(async () => { - // clean up the database before each test - await prisma.inviteCodeRequest.deleteMany(); - await prisma.inviteCodeUse.deleteMany(); - await prisma.inviteCode.deleteMany(); - await prisma.profile.deleteMany(); - await prisma.identitiesOnDevice.deleteMany(); - await prisma.deviceIdentity.deleteMany(); - await prisma.device.deleteMany(); -}); - -// Helper function to create a test user with DeviceIdentity -async function createTestUser(suffix = "", xmtpId = AUTH_USER_XMTP_ID) { - // Create DeviceIdentity directly - const deviceIdentity = await prisma.deviceIdentity.create({ - data: { - xmtpId, - identityAddress: `test-turnkey-address${suffix}`, - profile: { - create: { - name: `Test User${suffix}`, - username: `test-user${suffix.replace(/-/g, "")}`, - description: "Test bio", - }, - }, - }, - include: { - profile: true, - }, - }); - - // Create device - const device = await prisma.device.create({ - data: { - id: `test-device-id${suffix}`, - os: DeviceOS.ios, - name: "Test Initial Device", - }, - }); - - // Link device and identity - await prisma.identitiesOnDevice.create({ - data: { - deviceId: device.id, - identityId: deviceIdentity.id, - xmtpInstallationId: `test-installation-id${suffix}`, - }, - }); - - return { - device: { - id: device.id, - os: device.os, - name: device.name, - }, - identity: { - id: deviceIdentity.id, - identityAddress: deviceIdentity.identityAddress, - xmtpId: deviceIdentity.xmtpId, - }, - profile: { - id: deviceIdentity.profile!.id, - name: deviceIdentity.profile!.name, - username: deviceIdentity.profile!.username, - description: deviceIdentity.profile!.description, - avatar: deviceIdentity.profile!.avatar, - }, - } as InitResponse; -} - -describe("/invites API", () => { - test("server is running", async () => { - const response = await fetch("http://localhost:3010/invites"); - console.log("Server test response:", response.status); - expect(response.status).toBeDefined(); - }); - - test("POST /invites creates a new invite code with minimal data", async () => { - // Create test user first - await createTestUser(); - - const createInviteBody = { - groupId: "test-group-id", - }; - - const response = await fetch("http://localhost:3010/invites", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createInviteBody), - }); - - if (response.status !== 201) { - console.log("Response status:", response.status); - const body = await response.text(); - console.log("Response body:", body); - - // Also check if the route is even being registered - const routeTestResponse = await fetch("http://localhost:3010/invites", { - method: "GET", - }); - console.log("GET route test status:", routeTestResponse.status); - } - expect(response.status).toBe(201); - - const inviteCode = (await response.json()) as CreateInviteCodeResponse; - expect(inviteCode.id).toBeDefined(); - expect(inviteCode.groupId).toBe("test-group-id"); - expect(inviteCode.name).toBe(null); - expect(inviteCode.description).toBe(null); - expect(inviteCode.imageUrl).toBe(null); - expect(inviteCode.maxUses).toBe(null); - expect(inviteCode.usesCount).toBe(0); - expect(inviteCode.status).toBe(InviteCodeStatus.ACTIVE); - expect(inviteCode.expiresAt).toBe(null); - expect(inviteCode.autoApprove).toBe(false); - expect(inviteCode.createdAt).toBeDefined(); - expect(inviteCode.inviteLinkURL).toBeDefined(); - expect(inviteCode.inviteLinkURL).toMatch(/^.*\/c[a-z0-9]{24}$/); - }); - - test("POST /invites creates a new invite code with all optional fields", async () => { - // Create test user first - await createTestUser("-full"); - - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days from now - const createInviteBody: CreateInviteCodeRequestBody = { - groupId: "test-group-id-full", - name: "Test Group Invite", - description: "This is a test group invite", - imageUrl: "https://example.com/image.jpg", - maxUses: 50, - expiresAt: expiresAt.toISOString(), - autoApprove: true, - notificationTargets: [], - }; - - const response = await fetch("http://localhost:3010/invites", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createInviteBody), - }); - - expect(response.status).toBe(201); - - const inviteCode = (await response.json()) as CreateInviteCodeResponse; - expect(inviteCode.id).toBeDefined(); - expect(inviteCode.groupId).toBe("test-group-id-full"); - expect(inviteCode.name).toBe("Test Group Invite"); - expect(inviteCode.description).toBe("This is a test group invite"); - expect(inviteCode.imageUrl).toBe("https://example.com/image.jpg"); - expect(inviteCode.maxUses).toBe(50); - expect(inviteCode.usesCount).toBe(0); - expect(inviteCode.status).toBe(InviteCodeStatus.ACTIVE); - expect(inviteCode.expiresAt).toBe(expiresAt.toISOString()); - expect(inviteCode.autoApprove).toBe(true); - expect(inviteCode.createdAt).toBeDefined(); - expect(inviteCode.inviteLinkURL).toBeDefined(); - expect(inviteCode.inviteLinkURL).toMatch(/^.*\/c[a-z0-9]{24}$/); - }); - - test("POST /invites validates required fields", async () => { - // Create test user first - await createTestUser("-validation"); - - const response = await fetch("http://localhost:3010/invites", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: "Test Group Invite", - // Missing required groupId - }), - }); - - expect(response.status).toBe(400); - - const error = (await response.json()) as { - success: boolean; - message: string; - errors: unknown; - }; - expect(error.success).toBe(false); - expect(error.message).toBe("Invalid request body"); - expect(error.errors).toBeDefined(); - }); - - test("POST /invites validates imageUrl format", async () => { - // Create test user first - await createTestUser("-image-validation"); - - const createInviteBody = { - groupId: "test-group-id", - imageUrl: "not-a-valid-url", - }; - - const response = await fetch("http://localhost:3010/invites", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createInviteBody), - }); - - expect(response.status).toBe(400); - - const error = (await response.json()) as { - success: boolean; - message: string; - errors: unknown; - }; - expect(error.success).toBe(false); - expect(error.message).toBe("Invalid request body"); - expect(error.errors).toBeDefined(); - }); - - test("POST /invites validates maxUses is positive", async () => { - // Create test user first - await createTestUser("-max-uses-validation"); - - const createInviteBody = { - groupId: "test-group-id", - maxUses: -1, - }; - - const response = await fetch("http://localhost:3010/invites", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createInviteBody), - }); - - expect(response.status).toBe(400); - - const error = (await response.json()) as { - success: boolean; - message: string; - errors: unknown; - }; - expect(error.success).toBe(false); - expect(error.message).toBe("Invalid request body"); - expect(error.errors).toBeDefined(); - }); - - test("POST /invites validates expiresAt is valid datetime", async () => { - // Create test user first - await createTestUser("-expires-validation"); - - const createInviteBody = { - groupId: "test-group-id", - expiresAt: "not-a-valid-date", - }; - - const response = await fetch("http://localhost:3010/invites", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createInviteBody), - }); - - expect(response.status).toBe(400); - - const error = (await response.json()) as { - success: boolean; - message: string; - errors: unknown; - }; - expect(error.success).toBe(false); - expect(error.message).toBe("Invalid request body"); - expect(error.errors).toBeDefined(); - }); - - test("POST /invites returns 404 when identity not found", async () => { - // Don't create a user, so identity won't be found - const createInviteBody = { - groupId: "test-group-id", - }; - - const response = await fetch("http://localhost:3010/invites", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createInviteBody), - }); - - expect(response.status).toBe(404); - - const error = (await response.json()) as { - success: boolean; - message: string; - }; - expect(error.success).toBe(false); - expect(error.message).toBe("Identity not found"); - }); - - test("POST /invites creates invite code with cuid as ID", async () => { - // Create test user first - await createTestUser("-cuid-test"); - - const createInviteBody = { - groupId: "test-group-id", - }; - - const response = await fetch("http://localhost:3010/invites", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createInviteBody), - }); - - expect(response.status).toBe(201); - - const inviteCode = (await response.json()) as CreateInviteCodeResponse; - // CUID should be 25 characters long and start with 'c' - expect(inviteCode.id).toMatch(/^c[a-z0-9]{24}$/); - expect(inviteCode.inviteLinkURL).toBeDefined(); - expect(inviteCode.inviteLinkURL).toMatch(/^.*\/c[a-z0-9]{24}$/); - }); - - test("POST /invites stores correct createdById", async () => { - // Create test user first - await createTestUser("-created-by-test"); - - const createInviteBody = { - groupId: "test-group-id", - }; - - const response = await fetch("http://localhost:3010/invites", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createInviteBody), - }); - - expect(response.status).toBe(201); - - const inviteCode = (await response.json()) as CreateInviteCodeResponse; - - // Verify the invite code was created with correct createdById - const dbInviteCode = await prisma.inviteCode.findUnique({ - where: { id: inviteCode.id }, - include: { createdBy: true }, - }); - - expect(dbInviteCode?.createdBy.xmtpId).toBe(AUTH_USER_XMTP_ID); - }); - - test("POST /invites handles default values correctly", async () => { - // Create test user first - await createTestUser("-defaults-test"); - - const createInviteBody = { - groupId: "test-group-id", - // Not setting autoApprove to test default value - }; - - const response = await fetch("http://localhost:3010/invites", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createInviteBody), - }); - - expect(response.status).toBe(201); - - const inviteCode = (await response.json()) as CreateInviteCodeResponse; - expect(inviteCode.autoApprove).toBe(false); // Default value - expect(inviteCode.status).toBe(InviteCodeStatus.ACTIVE); // Default value - expect(inviteCode.usesCount).toBe(0); // Default value - expect(inviteCode.inviteLinkURL).toBeDefined(); - expect(inviteCode.inviteLinkURL).toMatch(/^.*\/c[a-z0-9]{24}$/); - }); - - test("POST /invites/:inviteId updates existing invite code", async () => { - // Create test user first - await createTestUser("-update-test"); - - // First create an invite - const createBody = { - groupId: "test-group-update", - name: "Original Name", - description: "Original Description", - autoApprove: false, - }; - - const createResponse = await fetch("http://localhost:3010/invites", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createBody), - }); - - expect(createResponse.status).toBe(201); - const originalInvite = - (await createResponse.json()) as CreateInviteCodeResponse; - - // Now update the invite - const updateBody = { - groupId: "test-group-update", - name: "Updated Name", - description: "Updated Description", - autoApprove: true, - status: InviteCodeStatus.DISABLED, - }; - - const updateResponse = await fetch( - `http://localhost:3010/invites/${originalInvite.id}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(updateBody), - }, - ); - - expect(updateResponse.status).toBe(200); - const updatedInvite = - (await updateResponse.json()) as CreateInviteCodeResponse; - - // Verify the update - expect(updatedInvite.id).toBe(originalInvite.id); // Same ID - expect(updatedInvite.name).toBe("Updated Name"); - expect(updatedInvite.description).toBe("Updated Description"); - expect(updatedInvite.autoApprove).toBe(true); - expect(updatedInvite.inviteLinkURL).toBe(originalInvite.inviteLinkURL); // Same URL - expect(updatedInvite.status).toBe(InviteCodeStatus.DISABLED); - }); - - test("POST /invites/:inviteId returns 404 for non-existent invite", async () => { - // Create test user first - await createTestUser("-update-404-test"); - - const updateBody = { - groupId: "test-group-404", - name: "Test Name", - }; - - const response = await fetch( - "http://localhost:3010/invites/non-existent-id", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(updateBody), - }, - ); - - expect(response.status).toBe(404); - const error = (await response.json()) as { - success: boolean; - message: string; - }; - expect(error.success).toBe(false); - expect(error.message).toBe("Invite not found"); - }); - - test("DELETE /invites/:inviteId deletes the invite (creator only)", async () => { - // Create creator user and invite - await createTestUser("-delete-invite-owner"); - - const createRes = await fetch("http://localhost:3010/invites", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ groupId: "group-to-delete", name: "To Delete" }), - }); - expect(createRes.status).toBe(201); - const invite = (await createRes.json()) as CreateInviteCodeResponse; - - // Fetch details (200) - const getBefore = await fetch( - `http://localhost:3010/invites/${invite.id}`, - { - method: "GET", - headers: { "Content-Type": "application/json" }, - }, - ); - expect(getBefore.status).toBe(200); - - // Delete as creator - const delRes = await fetch(`http://localhost:3010/invites/${invite.id}`, { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - }); - expect(delRes.status).toBe(200); - const del = (await delRes.json()) as { id: string; deleted: boolean }; - expect(del.id).toBe(invite.id); - expect(del.deleted).toBe(true); - - // Fetch again (404) - const getAfter = await fetch(`http://localhost:3010/invites/${invite.id}`, { - method: "GET", - headers: { "Content-Type": "application/json" }, - }); - expect(getAfter.status).toBe(404); - }); - - test("DELETE /invites/:inviteId by non-creator is forbidden", async () => { - // Create creator and invite - await createTestUser("-delete-invite-owner2"); - const createRes = await fetch("http://localhost:3010/invites", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - groupId: "group-to-delete2", - name: "To Delete 2", - }), - }); - expect(createRes.status).toBe(201); - const invite = (await createRes.json()) as CreateInviteCodeResponse; - - // Create another user - const OTHER_XMTP_ID = "delete-invite-other-user"; - await createTestUser("-delete-invite-other", OTHER_XMTP_ID); - - // Attempt delete as non-creator - const delRes = await fetch(`http://localhost:3010/invites/${invite.id}`, { - method: "DELETE", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": OTHER_XMTP_ID, - }, - }); - expect(delRes.status).toBe(403); - const err = (await delRes.json()) as { success: boolean; message: string }; - expect(err.success).toBe(false); - expect(err.message).toBe("Not authorized to delete this invite"); - - // Ensure invite still exists (creator can fetch) - const getRes = await fetch(`http://localhost:3010/invites/${invite.id}`, { - method: "GET", - headers: { "Content-Type": "application/json" }, - }); - expect(getRes.status).toBe(200); - }); - - test("POST /invites/:inviteId returns 403 for unauthorized update", async () => { - // Create first user and invite - await createTestUser("-owner"); - const createBody = { - groupId: "test-group-auth", - name: "Owner's Invite", - }; - - const createResponse = await fetch("http://localhost:3010/invites", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createBody), - }); - - expect(createResponse.status).toBe(201); - const invite = (await createResponse.json()) as CreateInviteCodeResponse; - - // Create second user who shouldn't be able to update - const secondUserApp = express(); - secondUserApp.use(pinoMiddleware); - secondUserApp.use(jsonMiddleware); - secondUserApp.use((req, _res, next) => { - _res.locals.xmtpId = "different-user-xmtp-id"; - _res.locals.xmtpInstallationId = "different-installation-id"; - next(); - }); - secondUserApp.use("/invites", invitesRouter); - const secondServer = secondUserApp.listen(3013); - - try { - // Create the second user in the database - await createTestUser("-unauthorized", "different-user-xmtp-id"); - - const updateBody = { - groupId: "test-group-auth", - name: "Hacked Name", - }; - - const response = await fetch( - `http://localhost:3013/invites/${invite.id}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(updateBody), - }, - ); - - expect(response.status).toBe(403); - const error = (await response.json()) as { - success: boolean; - message: string; - }; - expect(error.success).toBe(false); - expect(error.message).toBe("Not authorized to update this invite"); - } finally { - secondServer.close(); - } - }); - - test("GET /invites/:inviteId returns full details for invite creator", async () => { - // Create test user first - await createTestUser("-get-details-owner"); - - // Create an invite - const createBody = { - groupId: "test-group-get-details", - name: "Test Get Details", - description: "Test description", - maxUses: 10, - autoApprove: true, - }; - - const createResponse = await fetch("http://localhost:3010/invites", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createBody), - }); - - expect(createResponse.status).toBe(201); - const invite = (await createResponse.json()) as CreateInviteCodeResponse; - - // Get invite details as the creator - const getResponse = await fetch( - `http://localhost:3010/invites/${invite.id}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }, - ); - - expect(getResponse.status).toBe(200); - const details = (await getResponse.json()) as GetInviteDetailsResponse; - - // Should return full details since this is the creator - expect(details.id).toBe(invite.id); - expect(details.name).toBe("Test Get Details"); - expect(details.description).toBe("Test description"); - expect(details.maxUses).toBe(10); - expect(details.usesCount).toBe(0); - expect(details.status).toBe(InviteCodeStatus.ACTIVE); - expect(details.autoApprove).toBe(true); - expect(details.groupId).toBe("test-group-get-details"); - expect(details.createdAt).toBeDefined(); - expect(details.inviteLinkURL).toBeDefined(); - }); - - test("GET /invites/:inviteId returns 403 for non-creator", async () => { - // Create first user and invite - await createTestUser("-get-details-creator"); - const createBody = { - groupId: "test-group-get-auth", - name: "Creator's Invite", - description: "Only creator should see this", - }; - - const createResponse = await fetch("http://localhost:3010/invites", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createBody), - }); - - expect(createResponse.status).toBe(201); - const invite = (await createResponse.json()) as CreateInviteCodeResponse; - - // Create second user who shouldn't be able to view details - const secondUserApp = express(); - secondUserApp.use(pinoMiddleware); - secondUserApp.use(jsonMiddleware); - secondUserApp.use((req, _res, next) => { - _res.locals.xmtpId = "different-user-get-details-xmtp-id"; - _res.locals.xmtpInstallationId = "different-installation-id"; - next(); - }); - secondUserApp.use("/invites", invitesRouter); - const secondServer = secondUserApp.listen(3014); - - try { - // Create the second user in the database - await createTestUser( - "-unauthorized-get", - "different-user-get-details-xmtp-id", - ); - - const getResponse = await fetch( - `http://localhost:3014/invites/${invite.id}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }, - ); - - expect(getResponse.status).toBe(403); - const error = (await getResponse.json()) as { - success: boolean; - message: string; - }; - expect(error.success).toBe(false); - expect(error.message).toBe( - "Forbidden: you don't have access to this invite", - ); - } finally { - secondServer.close(); - } - }); - - test("GET /invites/:inviteId returns 404 for non-existent invite", async () => { - // Create test user first - await createTestUser("-get-404-test"); - - const response = await fetch( - "http://localhost:3010/invites/non-existent-invite-id", - { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }, - ); - - expect(response.status).toBe(404); - const error = (await response.json()) as { - success: boolean; - message: string; - }; - expect(error.success).toBe(false); - expect(error.message).toBe("Invite not found"); - }); - - test("GET /invites/:inviteId returns 404 when user identity not found", async () => { - // Don't create a user, so identity won't be found - const response = await fetch( - "http://localhost:3010/invites/some-invite-id", - { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }, - ); - - expect(response.status).toBe(404); - const error = (await response.json()) as { - success: boolean; - message: string; - }; - expect(error.success).toBe(false); - expect(error.message).toBe("Identity not found"); - }); -}); diff --git a/tests/notifications-invites.test.ts b/tests/notifications-invites.test.ts deleted file mode 100644 index c67f26f9..00000000 --- a/tests/notifications-invites.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import type { Server } from "http"; -import { DeviceOS, type InviteCode } from "@prisma/client"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - mock, - spyOn, - test, -} from "bun:test"; -import express from "express"; -import { rimrafSync } from "rimraf"; -import type { - InviteJoinRequestNotificationData, - NotificationPayload, -} from "@/api/shared/notifications/services/notifications-types"; -import * as PushService from "@/api/shared/notifications/services/push-notification.service"; -import type { RequestToJoinRequestBody } from "@/api/v1/invites/handlers/request-to-join"; -import invitesRouter from "@/api/v1/invites/invites.router"; -import { jsonMiddleware } from "@/middleware/json"; -import { pinoMiddleware } from "@/middleware/pino"; -import { prisma } from "@/utils/prisma"; - -// Mock functions for push notification service -const mockSendPushNotificationToXmtpId = mock< - (args: { - xmtpId: string; - notification: NotificationPayload; - }) => Promise<{ success: boolean }> ->(() => Promise.resolve({ success: true })); - -describe("Invite Notifications Integration", () => { - let app: express.Application; - let server: Server; - let inviteCode: InviteCode; - - beforeAll(() => { - // Set up Express app - app = express(); - app.use(pinoMiddleware); - app.use(jsonMiddleware); - - // Mock authentication middleware - app.use((req, _res, next) => { - const overrideXmtpId = req.headers["x-test-xmtp-id"]; - _res.locals.xmtpId = - typeof overrideXmtpId === "string" - ? overrideXmtpId - : "test-default-xmtp-id"; - _res.locals.xmtpInstallationId = "test-installation-id"; - next(); - }); - - app.use("/invites", invitesRouter); - server = app.listen(3015); - }); - - afterAll(async () => { - await cleanup(); - await prisma.$disconnect(); - server.close(); - rimrafSync("tests/**/*.db3*", { glob: true }); - // Restore all mocks - mock.restore(); - }); - - beforeEach(async () => { - // Clear and reset mocks - mockSendPushNotificationToXmtpId.mockClear(); - mockSendPushNotificationToXmtpId.mockResolvedValue({ success: true }); - - // Spy on the specific methods we care about on the actual service - const pushService = PushService.getPushNotificationService(); - spyOn(pushService, "sendPushNotificationToXmtpId").mockImplementation( - mockSendPushNotificationToXmtpId, - ); - spyOn(pushService, "sendPushNotification").mockResolvedValue({ - success: true, - }); - - // Create test data - inviteCode = await setupTestData(); - }); - - afterEach(async () => { - // Clean up mocks - mockSendPushNotificationToXmtpId.mockClear(); - mock.restore(); - await cleanup(); - }); - - const cleanup = async () => { - await prisma.inviteCodeNotificationTarget.deleteMany(); - await prisma.inviteCodeRequest.deleteMany(); - await prisma.inviteCodeUse.deleteMany(); - await prisma.inviteCode.deleteMany(); - await prisma.profile.deleteMany(); - await prisma.identitiesOnDevice.deleteMany(); - await prisma.deviceIdentity.deleteMany(); - await prisma.device.deleteMany(); - }; - - const setupTestData = async (): Promise => { - // Create invite creator identity and device - const creatorIdentity = await prisma.deviceIdentity.create({ - data: { - xmtpId: "test-creator-xmtp-id-notifications", - identityAddress: "0x1234creator", - profile: { - create: { - name: "Test Creator", - username: "testcreator", - description: "Test creator user", - }, - }, - }, - include: { profile: true }, - }); - const creatorDevice = await prisma.device.create({ - data: { - id: "test-device-creator-notifications", - os: DeviceOS.ios, - name: "Creator Device", - pushToken: "creator-push-token", - pushTokenType: "apns", - apnsEnv: "sandbox", - }, - }); - await prisma.identitiesOnDevice.create({ - data: { - deviceId: creatorDevice.id, - identityId: creatorIdentity.id, - }, - }); - - // Create requester identity and device - const requesterIdentity = await prisma.deviceIdentity.create({ - data: { - xmtpId: "test-requester-xmtp-id-notifications", - identityAddress: "0x1234requester", - profile: { - create: { - name: "Test Requester", - username: "testrequester", - description: "Test requester user", - }, - }, - }, - include: { profile: true }, - }); - const requesterDevice = await prisma.device.create({ - data: { - id: "test-device-requester-notifications", - os: DeviceOS.android, - name: "Requester Device", - pushToken: "requester-push-token", - pushTokenType: "apns", - apnsEnv: "sandbox", - }, - }); - await prisma.identitiesOnDevice.create({ - data: { - deviceId: requesterDevice.id, - identityId: requesterIdentity.id, - }, - }); - - // Create notification target identity and device - const notifyIdentity = await prisma.deviceIdentity.create({ - data: { - xmtpId: "test-notification-target-xmtp-id", - identityAddress: "0x1234notificationtarget", - profile: { - create: { - name: "Test Notification Target", - username: "testnotificationtarget", - description: "Test notification target user", - }, - }, - }, - include: { profile: true }, - }); - const notifyDevice = await prisma.device.create({ - data: { - id: "test-device-notification-target", - os: DeviceOS.ios, - name: "Notification Target Device", - pushToken: "notification-target-push-token", - pushTokenType: "apns", - apnsEnv: "sandbox", - }, - }); - await prisma.identitiesOnDevice.create({ - data: { - deviceId: notifyDevice.id, - identityId: notifyIdentity.id, - }, - }); - - // Create invite code - const createdInviteCode = await prisma.inviteCode.create({ - data: { - groupId: "test-group-notifications-123", - name: "Test Notification Group Invite", - description: "Test invite for notification testing", - autoApprove: false, // Require approval to trigger notifications - createdById: creatorIdentity.id, - }, - }); - - // Get notification target identity - const notificationTargetIdentity = await prisma.deviceIdentity.findFirst({ - where: { xmtpId: "test-notification-target-xmtp-id" }, - }); - - // Add notification target to the invite - await prisma.inviteCodeNotificationTarget.create({ - data: { - inviteCodeId: createdInviteCode.id, - deviceIdentityId: notificationTargetIdentity!.id, - }, - }); - - return createdInviteCode; - }; - - test("sends push notification when user requests to join invite", async () => { - const requestBody: RequestToJoinRequestBody = { - inviteId: inviteCode.id, - }; - - // Make API request as the requester - const response = await fetch("http://localhost:3015/invites/request", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": "test-requester-xmtp-id-notifications", - }, - body: JSON.stringify(requestBody), - }); - - expect(response.status).toBe(201); - - // Verify push notifications were sent to both creator and notification target - expect(mockSendPushNotificationToXmtpId).toHaveBeenCalledTimes(2); - - const calls = mockSendPushNotificationToXmtpId.mock.calls; - const sentToXmtpIds = calls.map((call) => call[0].xmtpId); - - // Verify notifications were sent to both creator and notification target - expect(sentToXmtpIds).toContain("test-creator-xmtp-id-notifications"); - expect(sentToXmtpIds).toContain("test-notification-target-xmtp-id"); - - // Verify notification payload structure (check first call) - const firstCall = calls[0][0]; - const notification = firstCall.notification; - expect(notification.notificationType).toBe("InviteJoinRequest"); - - // Verify notification data structure - const notificationData = - notification.notificationData as InviteJoinRequestNotificationData; - expect(notificationData.id).toBeDefined(); - expect(notificationData.createdAt).toBeDefined(); - expect(notificationData.updatedAt).toBeDefined(); - expect(notificationData.autoApprove).toBe(false); - - // Verify requester data - expect(notificationData.requester.xmtpId).toBe( - "test-requester-xmtp-id-notifications", - ); - expect(notificationData.requester.profile?.name).toBe("Test Requester"); - expect(notificationData.requester.profile?.username).toBe("testrequester"); - expect(notificationData.requester.profile?.description).toBe( - "Test requester user", - ); - - // Verify invite code data - expect(notificationData.inviteCode.id).toBe(inviteCode.id); - expect(notificationData.inviteCode.name).toBe( - "Test Notification Group Invite", - ); - expect(notificationData.inviteCode.description).toBe( - "Test invite for notification testing", - ); - expect(notificationData.inviteCode.groupId).toBe( - "test-group-notifications-123", - ); - }); -}); diff --git a/tests/notifications-service.test.ts b/tests/notifications-service.test.ts index 33bd8736..e8a56ee5 100644 --- a/tests/notifications-service.test.ts +++ b/tests/notifications-service.test.ts @@ -170,23 +170,6 @@ describe("PushNotificationService", () => { expect(mockSendPushNotification).not.toHaveBeenCalled(); }); - test("rejects FCM push token type (not supported)", async () => { - const fcmDevice = { - ...testDevice, - pushTokenType: "fcm" as const, - }; - - const identityOnDevice = createTestIdentityOnDevice(fcmDevice); - - const result = await pushService.sendPushNotification({ - identityOnDevice, - notification: protocolNotification, - }); - - expect(result.success).toBe(false); - expect(mockSendPushNotification).not.toHaveBeenCalled(); - }); - test("handles APNS failure and increments push failures", async () => { // Mock APNS service to return failure mockSendPushNotification.mockResolvedValue({ diff --git a/tests/notifications.test.ts b/tests/notifications.test.ts deleted file mode 100644 index 75fdd985..00000000 --- a/tests/notifications.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -import type { Server } from "http"; -import { DeviceOS } from "@prisma/client"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - test, -} from "bun:test"; -import express from "express"; -import { uint8ArrayToHex } from "uint8array-extras"; -import type { - ICurrentRegisterInstallationRequestBody, - IRegisterInstallationResponse, -} from "@/api/v1/notifications/handlers/register-installation"; -import { notificationsRouter } from "@/api/v1/notifications/notifications.router"; -import { jsonMiddleware } from "@/middleware/json"; -import { pinoMiddleware } from "@/middleware/pino"; -import { createNotificationClient } from "@/notifications/client"; -import { prisma } from "@/utils/prisma"; - -const app = express(); -app.use(jsonMiddleware); -app.use(pinoMiddleware); - -const AUTH_XMTP_ID = "test-auth-xmtp-id"; - -app.use((req, res, next) => { - res.locals.xmtpId = AUTH_XMTP_ID; - next(); -}); - -app.use("/notifications", notificationsRouter); - -let server: Server; -const notificationClient = createNotificationClient(); - -const genericTestInstallationId1 = "test-install-id-1-for-subs"; -const genericTestInstallationId2 = "test-install-id-2-for-meta-subs"; -const genericTestInstallationId3 = "test-install-id-3-for-unsub"; -const genericTestInstallationId4 = "test-install-id-4-for-unsub-validate"; - -beforeAll(() => { - server = app.listen(3008); -}); - -afterAll(async () => { - await prisma.$disconnect(); - server.close(); -}); - -describe("/notifications API - Register/Unregister (Auth Required)", () => { - let testDeviceId: string; - let testIdentityId: string; - const testDeviceName = "RegUnreg Test Device"; - const testIdentityAddress = "reg-unreg-turnkey-address"; - - beforeEach(async () => { - await prisma.identitiesOnDevice.deleteMany(); - await prisma.profile.deleteMany(); - await prisma.deviceIdentity.deleteMany(); - await prisma.device.deleteMany(); - - const identity = await prisma.deviceIdentity.create({ - data: { - xmtpId: AUTH_XMTP_ID, - identityAddress: testIdentityAddress, - }, - }); - testIdentityId = identity.id; - - const device = await prisma.device.create({ - data: { - id: "test-device-id", - name: testDeviceName, - os: DeviceOS.ios, - }, - }); - testDeviceId = device.id; - - await prisma.identitiesOnDevice.create({ - data: { deviceId: testDeviceId, identityId: testIdentityId }, - }); - }); - - afterEach(async () => { - try { - await notificationClient.deleteInstallation({ - installationId: "test-installation-id-register", - }); - await notificationClient.deleteInstallation({ - installationId: "test-installation-id-unregister-setup", - }); - await notificationClient.deleteInstallation({ - installationId: "test-forbidden-device-install-id", - }); - } catch { - /* ignore */ - } - }); - - test("POST /notifications/register creates a new installation", async () => { - const testXmtpInstallationId = "test-installation-id-register"; - const response = await fetch( - "http://localhost:3008/notifications/register", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - deviceId: testDeviceId, - pushToken: "test-push-token-register", - pushTokenType: "apns" as const, - apnsEnv: "sandbox", - installations: [ - { - identityId: AUTH_XMTP_ID, - xmtpInstallationId: testXmtpInstallationId, - }, - ], - } satisfies ICurrentRegisterInstallationRequestBody), - }, - ); - - expect(response.status).toBe(201); - const data = (await response.json()) as IRegisterInstallationResponse; - expect(data[0].status).toBe("success"); - - const iod = await prisma.identitiesOnDevice.findUnique({ - where: { - deviceId_identityId: { - deviceId: testDeviceId, - identityId: testIdentityId, - }, - }, - }); - expect(iod?.xmtpInstallationId).toBe(testXmtpInstallationId); - const device = await prisma.device.findUnique({ - where: { id: testDeviceId }, - }); - expect(device?.pushToken).toBe("test-push-token-register"); - expect(device?.apnsEnv).toBe("sandbox"); - }); - - test("POST /notifications/register validates request body (missing fields)", async () => { - const response = await fetch( - "http://localhost:3008/notifications/register", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - deviceId: testDeviceId, - pushToken: "test-push-token-register", - }), - }, - ); - expect(response.status).toBe(400); - const data = (await response.json()) as { error: unknown }; - expect(data.error).toBeDefined(); - }); - - test("POST /notifications/register returns 403 if device not owned by authenticated user", async () => { - const response = await fetch( - "http://localhost:3008/notifications/register", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - deviceId: "wrong-device-id", - pushToken: "test-push-token-forbidden", - pushTokenType: "apns" as const, - apnsEnv: "sandbox", - installations: [ - { - identityId: AUTH_XMTP_ID, - xmtpInstallationId: "test-installation-id-register", - }, - ], - } satisfies ICurrentRegisterInstallationRequestBody), - }, - ); - expect(response.status).toBe(403); - }); - - test("DELETE /notifications/unregister/:xmtpInstallationId deletes an installation", async () => { - const xmtpInstallationIdToTest = "test-installation-id-unregister-setup"; - const regResponse = await fetch( - "http://localhost:3008/notifications/register", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - deviceId: testDeviceId, - pushToken: "token-for-unregister-push", - pushTokenType: "apns" as const, - apnsEnv: "sandbox", - installations: [ - { - identityId: AUTH_XMTP_ID, - xmtpInstallationId: xmtpInstallationIdToTest, - }, - ], - } satisfies ICurrentRegisterInstallationRequestBody), - }, - ); - - expect(regResponse.status).toBe(201); - - const unregResponse = await fetch( - `http://localhost:3008/notifications/unregister/${xmtpInstallationIdToTest}`, - { - method: "DELETE", - }, - ); - expect(unregResponse.status).toBe(200); - - const iod = await prisma.identitiesOnDevice.findUnique({ - where: { - deviceId_identityId: { - deviceId: testDeviceId, - identityId: testIdentityId, - }, - }, - }); - expect(iod?.xmtpInstallationId).toBeNull(); - }); -}); - -describe("/notifications API - Subscribe/Unsubscribe (No local DB auth needed, using XMTP installationId)", () => { - test("POST /notifications/subscribe handles simple topic subscription", async () => { - const response = await fetch( - "http://localhost:3008/notifications/subscribe", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - installationId: genericTestInstallationId1, - topics: ["test-topic-1", "test-topic-2"], - }), - }, - ); - expect(response.status).toBe(200); - }); - - test("POST /notifications/subscribe handles subscription with metadata", async () => { - const response = await fetch( - "http://localhost:3008/notifications/subscribe", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - installationId: genericTestInstallationId2, - subscriptions: [ - { - topic: "test-topic-1", - isSilent: true, - hmacKeys: [ - { - thirtyDayPeriodsSinceEpoch: 1, - key: uint8ArrayToHex(new Uint8Array([1, 2, 3])), - }, - ], - }, - { - topic: "test-topic-2", - isSilent: false, - hmacKeys: [ - { - thirtyDayPeriodsSinceEpoch: 1, - key: uint8ArrayToHex(new Uint8Array([1, 2, 3])), - }, - ], - }, - ], - }), - }, - ); - expect(response.status).toBe(200); - }); - - test("POST /notifications/unsubscribe removes topic subscriptions", async () => { - const response = await fetch( - "http://localhost:3008/notifications/unsubscribe", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - installationId: genericTestInstallationId3, - topics: ["test-topic-1", "test-topic-2"], - }), - }, - ); - expect(response.status).toBe(200); - }); - - test("POST /notifications/subscribe validates request body for missing installationId", async () => { - const response = await fetch( - "http://localhost:3008/notifications/subscribe", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ topics: ["test-topic"] }), - }, - ); - expect(response.status).toBe(400); - const data = (await response.json()) as { - error?: string; - }; - expect(data.error).toBeDefined(); - }); - - test("POST /notifications/unsubscribe validates request body for missing topics", async () => { - const response = await fetch( - "http://localhost:3008/notifications/unsubscribe", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ installationId: genericTestInstallationId4 }), - }, - ); - expect(response.status).toBe(400); - const data = (await response.json()) as { - error?: string; - }; - expect(data.error).toBeDefined(); - }); -}); diff --git a/tests/profiles-batch.test.ts b/tests/profiles-batch.test.ts deleted file mode 100644 index 7c0cb45f..00000000 --- a/tests/profiles-batch.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { Server } from "http"; -import { type DeviceIdentity, type Profile } from "@prisma/client"; -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import express from "express"; -import profilesRouter from "@/api/v1/profiles/profiles.router"; -import type { BatchProfilesResponse } from "@/api/v1/profiles/profiles.types"; -import { jsonMiddleware } from "@/middleware/json"; -import { prisma } from "@/utils/prisma"; - -const app = express(); -app.use(jsonMiddleware); - -// Add type declaration to augment Request type -declare module "express-serve-static-core" { - interface Request { - auth?: { - userId: string; - }; - } -} - -// Mock auth middleware for tests -app.use((req, res, next) => { - req.auth = { - userId: "test-user-id", - }; - next(); -}); - -// Register routes -app.use("/profiles", profilesRouter); - -let server: Server; -let baseUrl: string; - -describe("Batch Profile endpoints", () => { - const testProfiles: (Profile & { deviceIdentity: DeviceIdentity })[] = []; - const testRunId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; - const xmtpIds = [ - `test-xmtp-id-1-${testRunId}`, - `test-xmtp-id-2-${testRunId}`, - `test-xmtp-id-3-${testRunId}`, - ]; - const testIdentityId = `test-user-id-batch-profiles-${testRunId}`; - - beforeAll(async () => { - // Wait a bit in CI environments - if (process.env.CI) { - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - // Your existing setup but with better error handling - try { - // Create test identity - await prisma.deviceIdentity.create({ - data: { - id: testIdentityId, - xmtpId: `test-turnkey-user-id-batch-profiles-${testRunId}`, - }, - }); - - server = app.listen(0); - - // Wait for server to be ready - await new Promise((resolve, reject) => { - server.on("listening", resolve); - server.on("error", reject); - setTimeout(() => { - reject(new Error("Server start timeout")); - }, 5000); - }); - - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("Could not get server port"); - } - baseUrl = `http://localhost:${address.port}`; - - // Create test device identities and profiles - for (let i = 0; i < xmtpIds.length; i++) { - const deviceIdentity = await prisma.deviceIdentity.create({ - data: { - xmtpId: xmtpIds[i], - identityAddress: `0x${i}123456789`, - }, - }); - - const profile = await prisma.profile.create({ - data: { - name: `Test User ${i}`, - username: `testuser${i}`, - description: `Test description ${i}`, - avatar: `https://example.com/avatar${i}.png`, - deviceIdentityId: deviceIdentity.id, - }, - include: { - deviceIdentity: true, - }, - }); - - testProfiles.push(profile); - } - } catch (error) { - console.error("Test setup failed:", error); - throw error; - } - }); - - afterAll(async () => { - // Clean up test data - for (const profile of testProfiles) { - await prisma.profile.delete({ - where: { id: profile.id }, - }); - - await prisma.deviceIdentity.delete({ - where: { id: profile.deviceIdentityId }, - }); - } - - // Delete test identity - await prisma.deviceIdentity.delete({ - where: { id: testIdentityId }, - }); - - server.close(); - }); - - test("POST /profiles/batch should return profiles for valid xmtpIds", async () => { - const response = await fetch(`${baseUrl}/profiles/batch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - xmtpIds: [xmtpIds[0], xmtpIds[1], "non-existent-id"], - }), - }); - - expect(response.status).toBe(200); - - const body = (await response.json()) as BatchProfilesResponse; - expect(body.profiles).toBeDefined(); - - // Should only return profiles for existing IDs - expect(Object.keys(body.profiles).length).toBe(2); - - // Verify profile data - expect(body.profiles[xmtpIds[0]]).toBeDefined(); - expect(body.profiles[xmtpIds[0]].name).toBe("Test User 0"); - expect(body.profiles[xmtpIds[0]].username).toBe("testuser0"); - - expect(body.profiles[xmtpIds[1]]).toBeDefined(); - expect(body.profiles[xmtpIds[1]].name).toBe("Test User 1"); - expect(body.profiles[xmtpIds[1]].username).toBe("testuser1"); - - // Non-existent ID should not be in the response - expect(body.profiles["non-existent-id"]).toBeUndefined(); - }); - - test("POST /profiles/batch should return 400 for invalid requests", async () => { - // Empty array - const emptyResponse = await fetch(`${baseUrl}/profiles/batch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ xmtpIds: [] }), - }); - - expect(emptyResponse.status).toBe(400); - - // Missing xmtpIds - const missingResponse = await fetch(`${baseUrl}/profiles/batch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - }); - - expect(missingResponse.status).toBe(400); - - // Too many IDs (mocking the validation) - const tooManyIdsArray = Array(1001).fill("id"); - const tooManyResponse = await fetch(`${baseUrl}/profiles/batch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ xmtpIds: tooManyIdsArray }), - }); - - expect(tooManyResponse.status).toBe(400); - }); -}); diff --git a/tests/profiles.test.ts b/tests/profiles.test.ts deleted file mode 100644 index 49604c8d..00000000 --- a/tests/profiles.test.ts +++ /dev/null @@ -1,939 +0,0 @@ -import type { Server } from "http"; -import { DeviceOS, type Profile } from "@prisma/client"; -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - test, -} from "bun:test"; -import express from "express"; -import type { - InitRequestBody, - InitResponse, -} from "@/api/v1/init/handlers/init"; -import initRouter from "@/api/v1/init/init.router"; -import { ProfileValidationErrorType } from "@/api/v1/profiles/handlers/validate-profile"; -import type { ProfileValidationResponse } from "@/api/v1/profiles/profile.types"; -import profilesRouter from "@/api/v1/profiles/profiles.router"; -import type { ProfileRequestResult } from "@/api/v1/profiles/profiles.types"; -import { jsonMiddleware } from "@/middleware/json"; -import { pinoMiddleware } from "@/middleware/pino"; -import { prisma } from "@/utils/prisma"; - -const app = express(); -app.use(jsonMiddleware); -app.use(pinoMiddleware); -// Mock authentication by allowing tests to override the authenticated XMTP ID via header -app.use((req, _res, next) => { - const overrideXmtpId = req.headers["x-test-xmtp-id"]; - if (typeof overrideXmtpId === "string") { - _res.locals.xmtpId = overrideXmtpId; - } - next(); -}); -app.use("/users", initRouter); -app.use("/profiles", profilesRouter); - -let server: Server; - -beforeAll(() => { - server = app.listen(3004); -}); - -afterAll(async () => { - await prisma.$disconnect(); - server.close(); -}); - -beforeEach(async () => { - // Clean up the database before each test - await prisma.profile.deleteMany(); - await prisma.identitiesOnDevice.deleteMany(); - await prisma.deviceIdentity.deleteMany(); - await prisma.device.deleteMany(); -}); - -const createUserBody: InitRequestBody = { - device: { - id: "test-device-id", - os: DeviceOS.ios, - name: "iPhone 14", - }, - identity: { - identityAddress: "test-turnkey-address", - xmtpId: "test-xmtp-id", - }, - profile: { - name: "TestProfile123", - username: "testuser123", - description: "Test Description", - }, -}; - -const firstUserBody: InitRequestBody = { - device: { - id: "test-device-id-6", - os: DeviceOS.ios, - name: "iPhone 14 Pro", - }, - identity: { - identityAddress: "test-turnkey-address-6", - xmtpId: "test-xmtp-id-6", - }, - profile: { - name: "ExistingUser123", - username: "existinguser123", - description: "First user description", - }, -}; - -const secondUserBody: InitRequestBody = { - device: { - id: "test-device-id-7", - os: DeviceOS.ios, - name: "iPhone 14", - }, - identity: { - identityAddress: "test-turnkey-address-7", - xmtpId: "test-xmtp-id-7", - }, - profile: { - name: "OriginalUser123", - username: "originaluser123", - description: "Second user description", - }, -}; - -describe("/profiles API", () => { - test("GET /profiles/:id returns 404 for non-existent profile", async () => { - const response = await fetch( - "http://localhost:3004/profiles/nonexistent-id", - ); - expect(response.status).toBe(404); - expect(await response.json()).toEqual({ error: "Profile not found" }); - }); - - test("GET /profiles/:id returns profile when exists", async () => { - const createUserResponse = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const createdUser = (await createUserResponse.json()) as InitResponse; - - const response = await fetch( - `http://localhost:3004/profiles/${createdUser.identity.xmtpId}`, - ); - const profile = (await response.json()) as Profile; - - expect(response.status).toBe(200); - expect(profile.id).toBe(createdUser.profile.id); - expect(profile.name).toBe("TestProfile123"); - expect(profile.description).toBe("Test Description"); - }); - - test("PUT /profiles/:id updates profile", async () => { - const createUserResponse = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const createdUser = (await createUserResponse.json()) as InitResponse; - - // Update the profile - const response = await fetch( - `http://localhost:3004/profiles/${createdUser.identity.xmtpId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": createUserBody.identity.xmtpId, - }, - body: JSON.stringify({ - name: "UpdatedName123", - username: "updated-user-123", - description: "Updated Description", - }), - }, - ); - const updatedProfile = (await response.json()) as Profile; - - expect(response.status).toBe(200); - expect(updatedProfile.id).toBe(createdUser.profile.id); - expect(updatedProfile.name).toBe("UpdatedName123"); - expect(updatedProfile.username).toBe("updated-user-123"); - expect(updatedProfile.description).toBe("Updated Description"); - }); - - test("PUT /profiles/:id returns 400 for invalid characters in name", async () => { - const createUserResponse = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const createdUser = (await createUserResponse.json()) as InitResponse; - - const response = await fetch( - `http://localhost:3004/profiles/${createdUser.identity.xmtpId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": createUserBody.identity.xmtpId, - }, - body: JSON.stringify({ - name: "Test@Profile", // Contains special characters - }), - }, - ); - - expect(response.status).toBe(400); - const result = (await response.json()) as ProfileValidationResponse; - expect(result.success).toBe(false); - expect(result.errors?.name).toEqual({ - type: ProfileValidationErrorType.INVALID_FORMAT, - message: "Name can only contain letters, numbers, spaces and dots", - }); - }); - - test("PUT /profiles/:id returns 400 for invalid characters in username", async () => { - const createUserResponse = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const createdUser = (await createUserResponse.json()) as InitResponse; - - const response = await fetch( - `http://localhost:3004/profiles/${createdUser.identity.xmtpId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": createUserBody.identity.xmtpId, - }, - body: JSON.stringify({ - username: "test_user!", // Contains underscore and special characters - }), - }, - ); - - expect(response.status).toBe(400); - const result = (await response.json()) as ProfileValidationResponse; - expect(result.success).toBe(false); - expect(result.errors?.username).toEqual({ - type: ProfileValidationErrorType.INVALID_FORMAT, - message: "Username can only contain letters, numbers and dashes", - }); - }); - - test("PUT /profiles/:id returns 400 for invalid request body", async () => { - const createUserResponse = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const createdUser = (await createUserResponse.json()) as InitResponse; - - const response = await fetch( - `http://localhost:3004/profiles/${createdUser.identity.xmtpId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": createUserBody.identity.xmtpId, - }, - body: JSON.stringify({ - name: 123, // Invalid type for name (should be string) - }), - }, - ); - - expect(response.status).toBe(400); - const result = (await response.json()) as ProfileValidationResponse; - expect(result.success).toBe(false); - expect(result.errors?.name).toEqual({ - type: ProfileValidationErrorType.INVALID_FORMAT, - message: "Expected string, received number", - }); - }); - - test("PUT /profiles/:id returns 400 for name too short", async () => { - const createUserResponse = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const createdUser = (await createUserResponse.json()) as InitResponse; - - const response = await fetch( - `http://localhost:3004/profiles/${createdUser.identity.xmtpId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": createUserBody.identity.xmtpId, - }, - body: JSON.stringify({ - name: "ab", // Too short (minimum 3 characters) - }), - }, - ); - - expect(response.status).toBe(400); - const result = (await response.json()) as ProfileValidationResponse; - expect(result.success).toBe(false); - expect(result.errors?.name).toEqual({ - type: ProfileValidationErrorType.INVALID_FORMAT, - message: "Name must be at least 3 characters long", - }); - }); - - test("PUT /profiles/:id returns 400 for name too long", async () => { - const createUserResponse = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const createdUser = (await createUserResponse.json()) as InitResponse; - - const response = await fetch( - `http://localhost:3004/profiles/${createdUser.identity.xmtpId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": createUserBody.identity.xmtpId, - }, - body: JSON.stringify({ - name: "a".repeat(51), // Too long (maximum 50 characters) - }), - }, - ); - - expect(response.status).toBe(400); - const result = (await response.json()) as ProfileValidationResponse; - expect(result.success).toBe(false); - expect(result.errors?.name).toEqual({ - type: ProfileValidationErrorType.INVALID_FORMAT, - message: "Name cannot exceed 50 characters", - }); - }); - - test("PUT /profiles/:id returns 400 for description too long", async () => { - const createUserResponse = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const createdUser = (await createUserResponse.json()) as InitResponse; - - const response = await fetch( - `http://localhost:3004/profiles/${createdUser.identity.xmtpId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": createUserBody.identity.xmtpId, - }, - body: JSON.stringify({ - description: "a".repeat(501), // Too long (maximum 500 characters) - }), - }, - ); - - expect(response.status).toBe(400); - const result = (await response.json()) as ProfileValidationResponse; - expect(result.success).toBe(false); - expect(result.errors?.description).toEqual({ - type: ProfileValidationErrorType.INVALID_FORMAT, - message: "Description cannot exceed 500 characters", - }); - }); - - test("PUT /profiles/:id returns 400 for invalid avatar URL", async () => { - const createUserResponse = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const createdUser = (await createUserResponse.json()) as InitResponse; - - const response = await fetch( - `http://localhost:3004/profiles/${createdUser.identity.xmtpId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": createUserBody.identity.xmtpId, - }, - body: JSON.stringify({ - avatar: "not-a-valid-url", // Invalid URL format - }), - }, - ); - - expect(response.status).toBe(400); - const result = (await response.json()) as ProfileValidationResponse; - expect(result.success).toBe(false); - expect(result.errors?.avatar).toEqual({ - type: ProfileValidationErrorType.INVALID_FORMAT, - message: "Avatar must be a valid URL", - }); - }); - - test("PUT /profiles/:id returns 409 for duplicate username", async () => { - await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(firstUserBody), - }); - - const createSecondUserResponse = await fetch( - "http://localhost:3004/users", - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(secondUserBody), - }, - ); - const secondUser = (await createSecondUserResponse.json()) as InitResponse; - - // Try to update second user's username to first user's username - const response = await fetch( - `http://localhost:3004/profiles/${secondUser.identity.xmtpId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": secondUserBody.identity.xmtpId, - }, - body: JSON.stringify({ - username: "existinguser123", // Already taken by first user - }), - }, - ); - - expect(response.status).toBe(409); - const result = (await response.json()) as ProfileValidationResponse; - expect(result.success).toBe(false); - expect(result.errors?.username).toEqual({ - type: ProfileValidationErrorType.USERNAME_TAKEN, - message: "This username is already taken", - }); - }); - - test("GET /profiles/search returns matching profiles", async () => { - // Create a user with a profile - await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - - // Search for the profile - const searchResponse = await fetch( - "http://localhost:3004/profiles/search?query=TestProfile123", - ); - const results = (await searchResponse.json()) as ProfileRequestResult[]; - - expect(searchResponse.status).toBe(200); - expect(results).toHaveLength(1); - expect(results[0].name).toBe("TestProfile123"); - expect(results[0].username).toBe("testuser123"); - expect(results[0].description).toBe("Test Description"); - }); - - test("GET /profiles/search is case insensitive", async () => { - // Create a user with a profile - await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - - // Search with lowercase - const searchResponse = await fetch( - "http://localhost:3004/profiles/search?query=testprofile123", - ); - const results = (await searchResponse.json()) as ProfileRequestResult[]; - - expect(searchResponse.status).toBe(200); - expect(results).toHaveLength(1); - expect(results[0].name).toBe("TestProfile123"); - expect(results[0].username).toBe("testuser123"); - expect(results[0].description).toBe("Test Description"); - }); - - test("GET /profiles/search returns 400 for empty query", async () => { - const searchResponse = await fetch( - "http://localhost:3004/profiles/search?query=", - ); - - expect(searchResponse.status).toBe(400); - expect(await searchResponse.json()).toEqual({ - error: "Invalid search query", - }); - }); - - test("GET /profiles/search returns empty array when no matches", async () => { - const searchResponse = await fetch( - "http://localhost:3004/profiles/search?query=NonexistentName", - ); - const results = (await searchResponse.json()) as ProfileRequestResult[]; - - expect(searchResponse.status).toBe(200); - expect(results).toHaveLength(0); - }); - - test("PUT /profiles/:id updates profile avatar", async () => { - // Create a user first with a profile - const createUserResponse = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const createdUser = (await createUserResponse.json()) as InitResponse; - - // Update just the avatar - const response = await fetch( - `http://localhost:3004/profiles/${createdUser.identity.xmtpId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": createUserBody.identity.xmtpId, - }, - body: JSON.stringify({ - avatar: "https://example.com/new-avatar.jpg", - }), - }, - ); - - const updatedProfile = (await response.json()) as Profile; - - expect(response.status).toBe(200); - expect(updatedProfile.id).toBe(createdUser.profile.id); - expect(updatedProfile.name).toBe("TestProfile123"); // unchanged - expect(updatedProfile.description).toBe("Test Description"); // unchanged - expect(updatedProfile.avatar).toBe("https://example.com/new-avatar.jpg"); - }); - - describe("GET /profiles/check/:username", () => { - test("returns false for non-existent username", async () => { - const response = await fetch( - "http://localhost:3004/profiles/check/nonexistent-user-123", - ); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ taken: false }); - }); - - test("returns true for existing username", async () => { - // Create a user first - await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - - const response = await fetch( - "http://localhost:3004/profiles/check/testuser123", - ); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ taken: true }); - }); - - test("is case insensitive", async () => { - // Create a user first - await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - - const response = await fetch( - "http://localhost:3004/profiles/check/TESTUSER123", - ); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ taken: true }); - }); - - test("returns 404 for empty username", async () => { - const response = await fetch("http://localhost:3004/profiles/check/"); - expect(response.status).toBe(404); - expect(await response.json()).toEqual({ error: "Profile not found" }); - }); - }); - - test("PUT /profiles/:id allows partial updates", async () => { - const createUserResponse = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const createdUser = (await createUserResponse.json()) as InitResponse; - - // Update only the name - const response = await fetch( - `http://localhost:3004/profiles/${createdUser.identity.xmtpId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": createUserBody.identity.xmtpId, - }, - body: JSON.stringify({ - name: "UpdatedName123", - }), - }, - ); - - const updatedProfile = (await response.json()) as Profile; - - expect(response.status).toBe(200); - expect(updatedProfile.name).toBe("UpdatedName123"); - expect(updatedProfile.username).toBe("testuser123"); // Should remain unchanged - expect(updatedProfile.description).toBe("Test Description"); // Should remain unchanged - }); - - test("PUT /profiles/:id validates name format", async () => { - const createUserResponse = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const createdUser = (await createUserResponse.json()) as InitResponse; - - const response = await fetch( - `http://localhost:3004/profiles/${createdUser.identity.xmtpId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": createUserBody.identity.xmtpId, - }, - body: JSON.stringify({ - name: "Test@Profile", // Contains special characters - }), - }, - ); - - expect(response.status).toBe(400); - const result = (await response.json()) as ProfileValidationResponse; - expect(result.success).toBe(false); - expect(result.errors?.name).toEqual({ - type: ProfileValidationErrorType.INVALID_FORMAT, - message: "Name can only contain letters, numbers, spaces and dots", - }); - }); - - test("POST /users allows optional profile fields", async () => { - const bodyWithOptionalFields = { - ...createUserBody, - profile: { - // Only description provided, name and username are optional - description: "Test Description", - }, - }; - - const response = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(bodyWithOptionalFields), - }); - - expect(response.status).toBe(201); - const user = (await response.json()) as InitResponse; - expect(user.profile.name).toBe(null); - expect(user.profile.username).toBe(null); - expect(user.profile.description).toBe("Test Description"); - }); - - test("PUT /profiles/:id allows optional fields to be omitted", async () => { - const createUserResponse = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const createdUser = (await createUserResponse.json()) as InitResponse; - - // Update with minimal data - const response = await fetch( - `http://localhost:3004/profiles/${createdUser.identity.xmtpId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": createUserBody.identity.xmtpId, - }, - body: JSON.stringify({ - username: "newuser123", - }), - }, - ); - - expect(response.status).toBe(200); - const updatedProfile = (await response.json()) as Profile; - expect(updatedProfile.username).toBe("newuser123"); - expect(updatedProfile.name).toBe("TestProfile123"); // Unchanged - expect(updatedProfile.description).toBe("Test Description"); // Unchanged - }); - - test("PUT /profiles/:id validates all provided fields", async () => { - const createUserResponse = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const createdUser = (await createUserResponse.json()) as InitResponse; - - const response = await fetch( - `http://localhost:3004/profiles/${createdUser.identity.xmtpId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": createUserBody.identity.xmtpId, - }, - body: JSON.stringify({ - name: "ab", // Too short - username: "test@123", // Invalid characters - description: "a".repeat(501), // Too long - avatar: "not-a-url", // Invalid URL - }), - }, - ); - - expect(response.status).toBe(400); - const result = (await response.json()) as ProfileValidationResponse; - expect(result.success).toBe(false); - expect(result.errors?.name).toEqual({ - type: ProfileValidationErrorType.INVALID_FORMAT, - message: "Name must be at least 3 characters long", - }); - expect(result.errors?.username).toEqual({ - type: ProfileValidationErrorType.INVALID_FORMAT, - message: "Username can only contain letters, numbers and dashes", - }); - expect(result.errors?.description).toEqual({ - type: ProfileValidationErrorType.INVALID_FORMAT, - message: "Description cannot exceed 500 characters", - }); - expect(result.errors?.avatar).toEqual({ - type: ProfileValidationErrorType.INVALID_FORMAT, - message: "Avatar must be a valid URL", - }); - }); - - test("PUT /profiles/:id validates on-chain name ownership", async () => { - const createUserResponse = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const createdUser = (await createUserResponse.json()) as InitResponse; - - const response = await fetch( - `http://localhost:3004/profiles/${createdUser.identity.xmtpId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": createUserBody.identity.xmtpId, - }, - body: JSON.stringify({ - name: "vitalik.eth", // On-chain name that the user doesn't own - }), - }, - ); - - expect(response.status).toBe(400); - const result = (await response.json()) as ProfileValidationResponse; - expect(result.success).toBe(false); - expect(result.errors?.name).toEqual({ - type: ProfileValidationErrorType.ONCHAIN_NAME_NOT_OWNED, - message: "You don't own this on-chain name", - }); - }); - - test("PUT /profiles/:id validates on-chain name ownership - success case", async () => { - // Create a user with vitalik's XMTP ID - const vitalikUserBody = { - ...createUserBody, - identity: { - identityAddress: "test-turnkey-address", - xmtpId: "vitalik-xmtp-id", // This will return vitalik's address in the mock - }, - }; - - const createUserResponse = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(vitalikUserBody), - }); - const createdUser = (await createUserResponse.json()) as InitResponse; - - // Try to update the name to vitalik.eth - const response = await fetch( - `http://localhost:3004/profiles/${createdUser.identity.xmtpId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": vitalikUserBody.identity.xmtpId, - }, - body: JSON.stringify({ - name: "vitalik.eth", - }), - }, - ); - - expect(response.status).toBe(200); - const updatedProfile = (await response.json()) as Profile; - expect(updatedProfile.name).toBe("vitalik.eth"); - }); - - test("PUT /profiles/:id validates on-chain name ownership - failure case", async () => { - const createUserResponse = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createUserBody), - }); - const createdUser = (await createUserResponse.json()) as InitResponse; - - const response = await fetch( - `http://localhost:3004/profiles/${createdUser.identity.xmtpId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": createUserBody.identity.xmtpId, - }, - body: JSON.stringify({ - name: "vitalik.eth", // Try to claim vitalik.eth with a different address - }), - }, - ); - - expect(response.status).toBe(400); - const result = (await response.json()) as ProfileValidationResponse; - expect(result.success).toBe(false); - expect(result.errors?.name).toEqual({ - type: ProfileValidationErrorType.ONCHAIN_NAME_NOT_OWNED, - message: "You don't own this on-chain name", - }); - }); - - test("PUT /profiles/:id forbids updating another user's profile", async () => { - // Create victim user - const victimBody: InitRequestBody = { - device: { - id: "victim-device-id", - os: DeviceOS.ios, - name: "Victim iPhone", - }, - identity: { identityAddress: "victim-address", xmtpId: "victim-xmtp-id" }, - profile: { - name: "Victim", - username: "victimuser", - description: "Victim bio", - }, - }; - const victimRes = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(victimBody), - }); - expect(victimRes.status).toBe(201); - - // Create attacker user - const attackerBody: InitRequestBody = { - device: { - id: "attacker-device-id", - os: DeviceOS.ios, - name: "Attacker iPhone", - }, - identity: { - identityAddress: "attacker-address", - xmtpId: "attacker-xmtp-id", - }, - profile: { - name: "Attacker", - username: "attackeruser", - description: "Attacker bio", - }, - }; - const attackerRes = await fetch("http://localhost:3004/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(attackerBody), - }); - expect(attackerRes.status).toBe(201); - - // Attempt to update victim profile while authenticated as attacker - const updateRes = await fetch( - `http://localhost:3004/profiles/${victimBody.identity.xmtpId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-test-xmtp-id": "attacker-xmtp-id", - }, - body: JSON.stringify({ name: "Hacked Name" }), - }, - ); - - // Expectation: should be forbidden (403). If this fails, it indicates a vulnerability. - expect(updateRes.status).toBe(403); - }); -}); diff --git a/tests/wallet.test.ts b/tests/wallet.test.ts deleted file mode 100644 index cd287183..00000000 --- a/tests/wallet.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { Server } from "http"; -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - mock, - test, -} from "bun:test"; -import express from "express"; -import { - DEFAULT_API_KEY_NAME, - DEFAULT_USER_NAME, -} from "@/api/v1/wallets/constants"; -import type { - CreateSubOrgRequestBody, - CreateSubOrgReturned, -} from "@/api/v1/wallets/handlers/create-suborg"; -import walletsRouter from "@/api/v1/wallets/wallets.router"; -import { jsonMiddleware } from "@/middleware/json"; -import { pinoMiddleware } from "@/middleware/pino"; -import { prisma } from "@/utils/prisma"; -import { getServerPort } from "./helpers"; - -const app = express(); -app.use(jsonMiddleware); -app.use(pinoMiddleware); -app.use("/wallets", walletsRouter); - -let server: Server; -let port: number; - -beforeAll(() => { - // start the server on a test port - server = app.listen(); - port = getServerPort(server); -}); - -afterAll(async () => { - // disconnect from the database - await prisma.$disconnect(); - // close the server - server.close(); -}); - -beforeEach(async () => { - await prisma.subOrg.deleteMany(); - mock.restore(); -}); - -describe("/wallets API", () => { - test("POST /wallets creates a new wallet", async () => { - const subOrgId = "foo"; - const walletAddress = "0x1234"; - const mockCreateSubOrg = mock(() => - Promise.resolve({ - subOrganizationId: subOrgId, - wallet: { - addresses: [walletAddress], - }, - }), - ); - - await mock.module("@turnkey/sdk-server", () => ({ - Turnkey: class { - apiClient() { - return { - createSubOrganization: mockCreateSubOrg, - }; - } - }, - })); - const createSubOrgBody: CreateSubOrgRequestBody = { - challenge: "test-challenge", - attestation: { - credentialId: "test-credential-id", - clientDataJson: "test-client-data-json", - attestationObject: "test-attestation-object", - transports: [], - }, - ephemeralPublicKey: "test-api-key", - }; - - const response = await fetch(`http://localhost:${port}/wallets`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createSubOrgBody), - }); - - expect(response.status).toBe(201); - - const data = (await response.json()) as CreateSubOrgReturned; - - expect(data.subOrgId).toEqual(subOrgId); - expect(data.walletAddress).toEqual(walletAddress); - expect(mockCreateSubOrg).toHaveBeenCalledTimes(1); - // Verify that the createSubOrganization was called with the correct parameters - expect(mockCreateSubOrg).toHaveBeenCalledWith( - expect.objectContaining({ - rootUsers: [ - expect.objectContaining({ - userName: DEFAULT_USER_NAME, - apiKeys: [ - expect.objectContaining({ - apiKeyName: DEFAULT_API_KEY_NAME, - publicKey: createSubOrgBody.ephemeralPublicKey, - curveType: "API_KEY_CURVE_P256", - }), - ], - authenticators: [ - { - authenticatorName: "Passkey", - challenge: createSubOrgBody.challenge, - attestation: createSubOrgBody.attestation, - }, - ], - }), - ], - }), - ); - - // Try again with the same credentialId - const response2 = await fetch(`http://localhost:${port}/wallets`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(createSubOrgBody), - }); - expect(response2.status).toBe(200); - - const data2 = (await response2.json()) as CreateSubOrgReturned; - expect(data2).toEqual(data); - expect(mockCreateSubOrg).toHaveBeenCalledTimes(1); - }); -});