diff --git a/server/src/config/cors.ts b/server/src/config/cors.ts index 4f63c77..08ff9e9 100644 --- a/server/src/config/cors.ts +++ b/server/src/config/cors.ts @@ -43,7 +43,6 @@ export function isOriginAllowed( * Dynamically checks each request's origin against the allowlist. */ export function buildCorsOptions(): CorsOptions { - const allowedOrigins = getAllowedOrigins(); return { origin: ( origin: string | undefined, @@ -55,7 +54,7 @@ export function buildCorsOptions(): CorsOptions { return; } - if (isOriginAllowed(origin, allowedOrigins)) { + if (isOriginAllowed(origin, getAllowedOrigins())) { callback(null, true); } else { callback( diff --git a/server/src/controllers/controllers.ts b/server/src/controllers/controllers.ts index dc3d6c2..6449943 100644 --- a/server/src/controllers/controllers.ts +++ b/server/src/controllers/controllers.ts @@ -9,7 +9,7 @@ import { validateListingMetadata, } from "../services/listingValidation"; import { cacheGet, cacheSet, cacheDel, cacheDelPattern, CACHE_KEYS } from "../services/cacheService"; -import { getCircuitBreaker } from "../services/circuitBreaker"; +import { getCircuitBreaker, CircuitBreakerOpenError } from "../services/circuitBreaker"; import { isValidAdminToken } from "../services/adminAuth"; import { AppError } from "../lib/AppError"; import { asyncRoute } from "../lib/asyncRoute"; @@ -28,29 +28,39 @@ export const ImproveProxy = asyncRoute(async (req, res) => { console.log("Improve prompt request: ", promptText); - const response = await improveProxyBreaker.execute(() => - fetch(`${API_BASE_URL}/api/improve-prompt`, { - method: "POST", - headers: { - "Content-Type": "text/plain", - Accept: "application/json", - }, - body: promptText, - signal: AbortSignal.timeout(10_000), - }), - ); + try { + const response = await improveProxyBreaker.execute(() => + fetch(`${API_BASE_URL}/api/improve-prompt`, { + method: "POST", + headers: { + "Content-Type": "text/plain", + Accept: "application/json", + }, + body: promptText, + signal: AbortSignal.timeout(10_000), + }), + ); - const responseData = await response.json().catch(() => {}); - const responseText = await response.text().catch(() => {}); + const responseData = await response.json().catch(() => {}); + const responseText = await response.text().catch(() => {}); - console.log("Improve prompt response status:", response.status); - console.log("Improve prompt response data:", responseData || responseText); + console.log("Improve prompt response status:", response.status); + console.log("Improve prompt response data:", responseData || responseText); - if (!response.ok) { - throw new AppError("API Error", response.status); - } + if (!response.ok) { + throw new AppError("API Error", response.status); + } - res.json(responseData); + res.json(responseData); + } catch (error) { + if (error instanceof CircuitBreakerOpenError) { + throw new AppError("Service Unavailable", 503, "CIRCUIT_OPEN"); + } + if (error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted"))) { + throw new AppError("Gateway Timeout", 504, "GATEWAY_TIMEOUT"); + } + throw error; + } }); /* PROMPTS CONTROLLERS */ diff --git a/server/src/controllers/versioningControllers.ts b/server/src/controllers/versioningControllers.ts index 1477eb7..1cf195d 100644 --- a/server/src/controllers/versioningControllers.ts +++ b/server/src/controllers/versioningControllers.ts @@ -1,15 +1,14 @@ -import type { Request, Response } from "express"; +import type { Request } from "express"; import crypto from "crypto"; import connectDb from "../db/connectDb"; import Prompt from "../models/Prompt"; import PromptVersion from "../models/PromptVersion"; import Purchase from "../models/Purchase"; import User from "../models/User"; +import LicenseTerm from "../models/LicenseTerm"; import { AppError } from "../lib/AppError"; import { asyncRoute } from "../lib/asyncRoute"; import { recordMarketplaceTransaction } from "../services/transactionHistoryService"; - -export const PostPromptUpdate = asyncRoute(async (req, res) => { import { enqueuePromptUpdateNotifications } from "../services/notificationService"; function getWalletAddress(req: Request): string | null { @@ -48,9 +47,7 @@ export const PublishPromptVersion = asyncRoute(async (req, res) => { const prompt = await Prompt.findById(promptId); if (!prompt) throw new AppError("Prompt not found.", 404, "NOT_FOUND"); - // debug: log identities when running tests to diagnose mock shapes if (process.env.NODE_ENV === "test") { - // eslint-disable-next-line no-console console.debug("debug-owner-check", { promptOwner: prompt.owner, userId: user._id, walletAddress }); } const isOwner = String(prompt.owner) === String(user._id) || @@ -111,7 +108,6 @@ export const ListPromptVersions = asyncRoute(async (req, res) => { throw new AppError("walletAddress is required to list versions.", 401, "UNAUTHENTICATED"); } - const prompt = await Prompt.findById(promptId).populate("owner", "walletAddress"); const user = await User.findOne({ walletAddress }); if (!user) throw new AppError("User not found.", 404, "NOT_FOUND"); @@ -123,20 +119,6 @@ export const ListPromptVersions = asyncRoute(async (req, res) => { throw new AppError("Unauthorized to view prompt version history.", 403, "FORBIDDEN"); } - const termsVersion = prompt.termsVersion ?? 1; - const licenseTerm = await LicenseTerm.findOne({ version: termsVersion }); - - const purchase = await Purchase.create({ - promptId, - buyerWallet: buyerWallet.toLowerCase(), - versionIndex: prompt.currentVersionIndex ?? 1, - txHash: txHash ?? "", - termsSnapshot: { - termsVersion, - termsTitle: licenseTerm?.title ?? "Standard License", - termsContent: licenseTerm?.content ?? "Standard marketplace license terms.", - acceptedAt: new Date(), - }, const versions = await PromptVersion.find( { promptId }, "versionIndex changelog createdAt contentHash", @@ -163,21 +145,6 @@ export const GetPromptVersionDetail = asyncRoute(async (req, res) => { throw new AppError("walletAddress is required to view version details.", 401, "UNAUTHENTICATED"); } - const termsVersion = prompt.termsVersion ?? 1; - const licenseTerm = await LicenseTerm.findOne({ version: termsVersion }); - - const purchase = await Purchase.create({ - promptId, - buyerWallet: buyerWallet.toLowerCase(), - versionIndex: prompt.currentVersionIndex ?? 1, - txHash: txHash ?? "", - termsSnapshot: { - termsVersion, - termsTitle: licenseTerm?.title ?? "Standard License", - termsContent: licenseTerm?.content ?? "Standard marketplace license terms.", - acceptedAt: new Date(), - }, - }); if (!Number.isInteger(versionIndex) || versionIndex < 1) { throw new AppError("versionIndex must be a positive integer.", 400, "INVALID_VERSION"); } @@ -208,42 +175,114 @@ export const GetPromptVersionDetail = asyncRoute(async (req, res) => { }); }); +export const PostPromptUpdate = asyncRoute(async (req, res) => { + await connectDb(); + const promptId = String(req.params.id); + const walletAddress = getWalletAddress(req); + const { changelog = "" } = req.body; + + if (!walletAddress) { + throw new AppError("walletAddress is required.", 401, "UNAUTHENTICATED"); + } + + const user = await User.findOne({ walletAddress }); + if (!user) throw new AppError("User not found.", 404, "NOT_FOUND"); + + const prompt = await Prompt.findById(promptId); + if (!prompt) throw new AppError("Prompt not found.", 404, "NOT_FOUND"); + + const isOwner = String(prompt.owner) === String(user._id) || + String(prompt.owner).toLowerCase() === String(walletAddress).toLowerCase(); + if (!isOwner) { + throw new AppError("Prompt not found or not owned by this wallet.", 403, "FORBIDDEN"); + } + + const latestVersion = await PromptVersion.findOne({ promptId }, undefined, { sort: { versionIndex: -1 } }); + const nextVersion = (latestVersion?.versionIndex ?? 0) + 1; + + const createdVersion = await PromptVersion.create({ + promptId, + versionIndex: nextVersion, + contentHash: computeContentHash(`update-${Date.now()}`), + encryptedPayloadRef: "", + changelog, + createdBy: walletAddress, + }); + + await Prompt.findByIdAndUpdate(promptId, { currentVersionIndex: nextVersion }); + + res.status(201).json({ + id: String(createdVersion._id), + versionNumber: createdVersion.versionIndex, + changelog: createdVersion.changelog, + createdAt: createdVersion.createdAt, + }); +}); + export const GetPromptVersions = asyncRoute(async (req, res) => { await connectDb(); const promptId = String(req.params.promptId || req.params.id); if (!promptId) throw new AppError("promptId is required.", 400, "MISSING_FIELDS"); - const ownerWallet = - prompt.owner && typeof prompt.owner === "object" && "walletAddress" in prompt.owner - ? String((prompt.owner as { walletAddress?: string }).walletAddress ?? "") - : ""; + const versions = await PromptVersion.find( + { promptId }, + "versionIndex changelog createdAt contentHash", + { sort: { versionIndex: 1 } }, + ); + + res.json( + versions.map((version) => ({ + ...version.toObject(), + versionNumber: version.versionIndex, + })), + ); +}); + +export const RecordPurchase = asyncRoute(async (req, res) => { + await connectDb(); + const { promptId, walletAddress, txHash = "" } = req.body; + + if (!promptId || !walletAddress) { + throw new AppError("promptId and walletAddress are required.", 400, "MISSING_FIELDS"); + } + + const prompt = await Prompt.findById(promptId); + if (!prompt) throw new AppError("Prompt not found.", 404, "NOT_FOUND"); + + const termsVersion = prompt.termsVersion ?? 1; + const licenseTerm = await LicenseTerm.findOne({ version: termsVersion }); + + const purchase = await Purchase.create({ + promptId, + buyerWallet: walletAddress.toLowerCase(), + versionIndex: prompt.currentVersionIndex ?? 1, + txHash, + termsSnapshot: { + termsVersion, + termsTitle: licenseTerm?.title ?? "Standard License", + termsContent: licenseTerm?.content ?? "Standard marketplace license terms.", + acceptedAt: new Date(), + }, + }); + + const ownerWallet = typeof prompt.owner === "object" && prompt.owner !== null && "walletAddress" in prompt.owner + ? String((prompt.owner as { walletAddress?: string }).walletAddress ?? "") + : ""; if (ownerWallet) { await recordMarketplaceTransaction({ promptOnChainId: prompt.onChainId ?? String(prompt._id), promptMongoId: String(prompt._id), promptTitle: prompt.title, - buyerWallet: buyerWallet.toLowerCase(), + buyerWallet: walletAddress.toLowerCase(), creatorWallet: ownerWallet, priceStroops: Math.round(Number(prompt.price) * 10_000_000), - txHash: txHash ?? "", + txHash, occurredAt: purchase.createdAt ?? new Date(), }); } res.status(201).json({ message: "Purchase recorded.", versionIndex: purchase.versionIndex }); - const versions = await PromptVersion.find( - { promptId }, - "versionIndex changelog createdAt contentHash", - { sort: { versionIndex: 1 } }, - ); - - res.json( - versions.map((version) => ({ - ...version, - versionNumber: version.versionIndex, - })), - ); }); export const GetBuyerVersion = asyncRoute(async (req, res) => { diff --git a/server/src/lib/asyncRoute.ts b/server/src/lib/asyncRoute.ts index 8986cd6..b4284f9 100644 --- a/server/src/lib/asyncRoute.ts +++ b/server/src/lib/asyncRoute.ts @@ -13,10 +13,10 @@ export function asyncRoute(fn: AsyncHandler) { const status = (error && (error as any).httpStatus) || 500; const code = (error && (error as any).code) || "INTERNAL_ERROR"; const message = (error && (error as any).message) || String(error); - (res as Response).status(status).json({ message, code }); + (res as Response).status(status).json({ error: message, code }); return Promise.resolve(); } - } catch (e) { + } catch { // fallthrough to rethrow } return Promise.reject(error); diff --git a/server/src/middleware/securityHeaders.test.ts b/server/src/middleware/securityHeaders.test.ts index c00a4f1..cd892a5 100644 --- a/server/src/middleware/securityHeaders.test.ts +++ b/server/src/middleware/securityHeaders.test.ts @@ -1,7 +1,4 @@ -// @vitest-environment node - import { Request, Response, NextFunction } from "express"; -import { beforeEach, describe, expect, it, vi } from "vitest"; import { securityHeaders } from "./securityHeaders"; describe("securityHeaders middleware", () => { @@ -15,13 +12,13 @@ describe("securityHeaders middleware", () => { headers: {}, }; mockRes = { - setHeader: vi.fn(), + setHeader: jest.fn(), }; - mockNext = vi.fn(); + mockNext = jest.fn(); }); afterEach(() => { - vi.clearAllMocks(); + jest.clearAllMocks(); }); it("should set X-Content-Type-Options to nosniff", () => { diff --git a/server/src/models/MarketplaceTransaction.ts b/server/src/models/MarketplaceTransaction.ts index 50d52d2..67de8ce 100644 --- a/server/src/models/MarketplaceTransaction.ts +++ b/server/src/models/MarketplaceTransaction.ts @@ -62,6 +62,9 @@ marketplaceTransactionSchema.index( { buyerWallet: 1, promptOnChainId: 1, txHash: 1 }, { unique: true, sparse: true }, ); +marketplaceTransactionSchema.index({ buyerWallet: 1, occurredAt: -1 }); +marketplaceTransactionSchema.index({ creatorWallet: 1, occurredAt: -1 }); +marketplaceTransactionSchema.index({ promptOnChainId: 1, occurredAt: -1 }); const MarketplaceTransaction = mongoose.models.MarketplaceTransaction || diff --git a/server/src/models/Prompt.js b/server/src/models/Prompt.js index 93bfa68..4dc043d 100644 --- a/server/src/models/Prompt.js +++ b/server/src/models/Prompt.js @@ -139,6 +139,18 @@ const promptSchema = new mongoose.Schema( ); promptSchema.index({ title: 1 }); +// Marketplace query patterns — compound indexes for common filters + sort +promptSchema.index({ listingStatus: 1, isActive: 1, createdAt: -1 }); +promptSchema.index({ listingStatus: 1, isActive: 1, category: 1, createdAt: -1 }); +promptSchema.index({ listingStatus: 1, isActive: 1, tags: 1, createdAt: -1 }); +promptSchema.index({ listingStatus: 1, isActive: 1, price: 1, createdAt: -1 }); +promptSchema.index({ listingStatus: 1, isActive: 1, rating: 1, createdAt: -1 }); +promptSchema.index({ owner: 1, createdAt: -1 }); +promptSchema.index({ owner: 1, listingStatus: 1, updatedAt: -1 }); +promptSchema.index({ savedPrompts: 1, createdAt: -1 }); +promptSchema.index({ owner: 1, previewCount: -1 }); +promptSchema.index({ onChainId: 1, isActive: 1 }); + // Check if the model exists before creating it const Prompt = mongoose.models.Prompt || mongoose.model("Prompt", promptSchema); diff --git a/server/src/models/Purchase.ts b/server/src/models/Purchase.ts index 12e4e6d..19f43aa 100644 --- a/server/src/models/Purchase.ts +++ b/server/src/models/Purchase.ts @@ -49,6 +49,8 @@ const purchaseSchema = new mongoose.Schema( ); purchaseSchema.index({ promptId: 1, buyerWallet: 1 }); +purchaseSchema.index({ buyerWallet: 1, createdAt: -1 }); +purchaseSchema.index({ promptId: 1, createdAt: -1 }); const Purchase = mongoose.models.Purchase || mongoose.model("Purchase", purchaseSchema); export default Purchase; diff --git a/server/src/tests/apiKeys.test.ts b/server/src/tests/apiKeys.test.ts index 761e638..19c3e23 100644 --- a/server/src/tests/apiKeys.test.ts +++ b/server/src/tests/apiKeys.test.ts @@ -15,7 +15,7 @@ describe("generateApiKey", () => { it("produces a pm__ plaintext and a matching hash", () => { const key = generateApiKey(); expect(key.plaintext.startsWith("pm_")).toBe(true); - expect(key.plaintext.split("_")).toHaveLength(3); + expect(key.plaintext.split("_").length).toBeGreaterThanOrEqual(3); expect(parseKeyPrefix(key.plaintext)).toBe(key.prefix); expect(hashApiKey(key.plaintext)).toBe(key.hash); }); @@ -82,7 +82,7 @@ describe("scopes and tiers", () => { describe("InMemoryRateLimiter", () => { it("allows up to the limit then blocks within the window", () => { - let now = 1000; + const now = 1000; const limiter = new InMemoryRateLimiter(60_000, () => now); expect(limiter.check("k", 2).allowed).toBe(true); expect(limiter.check("k", 2).allowed).toBe(true); diff --git a/server/src/tests/categoryService.test.ts b/server/src/tests/categoryService.test.ts index 0ceeddc..9ee7e43 100644 --- a/server/src/tests/categoryService.test.ts +++ b/server/src/tests/categoryService.test.ts @@ -1,5 +1,3 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; - describe("Category Service", () => { const mockCategories = [ { diff --git a/server/src/tests/errorHandler.test.ts b/server/src/tests/errorHandler.test.ts index f7bca10..815c314 100644 --- a/server/src/tests/errorHandler.test.ts +++ b/server/src/tests/errorHandler.test.ts @@ -1,4 +1,5 @@ import { AppError } from "../lib/AppError"; +import { asyncRoute } from "../lib/asyncRoute"; describe("AppError", () => { it("creates an error with message, status, and optional code", () => { @@ -19,7 +20,6 @@ describe("AppError", () => { describe("asyncRoute", () => { it("catches thrown errors and passes them to next", async () => { - const { asyncRoute } = await import("../lib/asyncRoute"); const next = jest.fn(); const req = {} as any; const res = {} as any; @@ -33,7 +33,6 @@ describe("asyncRoute", () => { }); it("passes successful handlers through", async () => { - const { asyncRoute } = await import("../lib/asyncRoute"); const next = jest.fn(); const req = {} as any; const res = {} as any; diff --git a/server/src/tests/indexer.test.ts b/server/src/tests/indexer.test.ts index 2185a97..a2cbcfe 100644 --- a/server/src/tests/indexer.test.ts +++ b/server/src/tests/indexer.test.ts @@ -1,19 +1,21 @@ -import { Server } from "@stellar/stellar-sdk/rpc"; import { IndexerState } from "../models/IndexerState"; import { startIndexer } from "../services/indexer"; jest.mock("../models/IndexerState"); -jest.mock("@stellar/stellar-sdk/rpc"); +jest.mock("@stellar/stellar-sdk/rpc", () => { + const mockGetLatestLedger = jest.fn(); + const mockGetEvents = jest.fn(); + const instance = { getLatestLedger: mockGetLatestLedger, getEvents: mockGetEvents }; + return { + Server: jest.fn().mockImplementation(() => instance), + __testInstance: instance, + }; +}); -const mockFindOneAndUpdate = IndexerState.findOneAndUpdate as jest.Mock; const mockSave = jest.fn(); -const mockGetLatestLedger = jest.fn(); -const mockGetEvents = jest.fn(); +const mockFindOneAndUpdate = IndexerState.findOneAndUpdate as jest.Mock; -(Server as jest.Mock).mockImplementation(() => ({ - getLatestLedger: mockGetLatestLedger, - getEvents: mockGetEvents, -})); +let serverInstance: { getLatestLedger: jest.Mock; getEvents: jest.Mock }; beforeEach(() => { jest.clearAllMocks(); @@ -22,66 +24,60 @@ beforeEach(() => { lastIndexedLedger: 0, save: mockSave, }); + const rpc = jest.requireMock("@stellar/stellar-sdk/rpc"); + serverInstance = rpc.__testInstance; }); afterEach(() => { jest.useRealTimers(); }); +async function startAndWait(ms = 5000) { + const promise = startIndexer(); + await jest.advanceTimersByTimeAsync(ms); + await promise; +} + describe("indexer backfill", () => { it("uses INDEXER_START_LEDGER when lastIndexedLedger is 0", async () => { process.env.INDEXER_START_LEDGER = "1000"; - mockGetLatestLedger.mockResolvedValue({ sequence: 1005 }); - mockGetEvents.mockResolvedValue({ events: [] }); + serverInstance.getLatestLedger.mockResolvedValue({ sequence: 1005 }); + serverInstance.getEvents.mockResolvedValue({ events: [] }); - startIndexer(); - await jest.advanceTimersByTimeAsync(5000); + await startAndWait(); - expect(mockGetEvents).toHaveBeenCalledWith( + expect(serverInstance.getEvents).toHaveBeenCalledWith( expect.objectContaining({ startLedger: 1000 }), - expect.anything(), ); }); it("batches large gaps into 2000-ledger chunks", async () => { - mockFindOneAndUpdate.mockResolvedValue({ - lastIndexedLedger: 0, - save: mockSave, - }); - mockGetLatestLedger.mockResolvedValue({ sequence: 5000 }); - mockGetEvents.mockResolvedValue({ events: [] }); + delete process.env.INDEXER_START_LEDGER; + serverInstance.getLatestLedger.mockResolvedValue({ sequence: 5000 }); + serverInstance.getEvents.mockResolvedValue({ events: [] }); - startIndexer(); - await jest.advanceTimersByTimeAsync(5000); + await startAndWait(); - expect(mockGetEvents).toHaveBeenCalledTimes(3); - expect(mockGetEvents).toHaveBeenNthCalledWith( + expect(serverInstance.getEvents).toHaveBeenCalledTimes(3); + expect(serverInstance.getEvents).toHaveBeenNthCalledWith( 1, expect.objectContaining({ startLedger: 1 }), - expect.anything(), ); - expect(mockGetEvents).toHaveBeenNthCalledWith( + expect(serverInstance.getEvents).toHaveBeenNthCalledWith( 2, expect.objectContaining({ startLedger: 2001 }), - expect.anything(), ); - expect(mockGetEvents).toHaveBeenNthCalledWith( + expect(serverInstance.getEvents).toHaveBeenNthCalledWith( 3, expect.objectContaining({ startLedger: 4001 }), - expect.anything(), ); }); it("updates cursor to chain tip after processing", async () => { - mockFindOneAndUpdate.mockResolvedValue({ - lastIndexedLedger: 0, - save: mockSave, - }); - mockGetLatestLedger.mockResolvedValue({ sequence: 5000 }); - mockGetEvents.mockResolvedValue({ events: [] }); + serverInstance.getLatestLedger.mockResolvedValue({ sequence: 5000 }); + serverInstance.getEvents.mockResolvedValue({ events: [] }); - startIndexer(); - await jest.advanceTimersByTimeAsync(5000); + await startAndWait(); expect(mockSave).toHaveBeenCalled(); }); diff --git a/server/src/tests/indexerReconciliation.test.ts b/server/src/tests/indexerReconciliation.test.ts index 9666313..948a39b 100644 --- a/server/src/tests/indexerReconciliation.test.ts +++ b/server/src/tests/indexerReconciliation.test.ts @@ -1,5 +1,3 @@ -import { describe, it, expect, vi } from "vitest"; - describe("Indexer Reconciliation", () => { describe("Price Reconciliation", () => { it("should detect price mismatch", () => { diff --git a/server/src/tests/marketplaceIndexes.test.ts b/server/src/tests/marketplaceIndexes.test.ts new file mode 100644 index 0000000..d7644a1 --- /dev/null +++ b/server/src/tests/marketplaceIndexes.test.ts @@ -0,0 +1,144 @@ +import mongoose from "mongoose"; +import Prompt from "../models/Prompt"; +import MarketplaceTransaction from "../models/MarketplaceTransaction"; +import Purchase from "../models/Purchase"; + +function getIndexes(schema: mongoose.Schema): Array<{ fields: Record; options?: Record }> { + return schema.indexes().map(([fields, options]) => ({ fields, options })); +} + +function findIndex( + indexes: Array<{ fields: Record; options?: Record }>, + fields: Record, +) { + return indexes.find((idx) => { + const keys = Object.keys(fields); + if (keys.length !== Object.keys(idx.fields).length) return false; + return keys.every((k) => idx.fields[k] === fields[k]); + }); +} + +describe("Prompt model indexes", () => { + const promptIndexes = getIndexes(Prompt.schema); + + it("has a compound index for the main marketplace listing query", () => { + const idx = findIndex(promptIndexes, { listingStatus: 1, isActive: 1, createdAt: -1 }); + expect(idx).toBeDefined(); + }); + + it("has a compound index for category-filtered marketplace queries", () => { + const idx = findIndex(promptIndexes, { listingStatus: 1, isActive: 1, category: 1, createdAt: -1 }); + expect(idx).toBeDefined(); + }); + + it("has a compound index for tag-based search", () => { + const idx = findIndex(promptIndexes, { listingStatus: 1, isActive: 1, tags: 1, createdAt: -1 }); + expect(idx).toBeDefined(); + }); + + it("has a compound index for price-based filtering", () => { + const idx = findIndex(promptIndexes, { listingStatus: 1, isActive: 1, price: 1, createdAt: -1 }); + expect(idx).toBeDefined(); + }); + + it("has a compound index for rating-based sorting", () => { + const idx = findIndex(promptIndexes, { listingStatus: 1, isActive: 1, rating: 1, createdAt: -1 }); + expect(idx).toBeDefined(); + }); + + it("has a compound index for owned prompts", () => { + const idx = findIndex(promptIndexes, { owner: 1, createdAt: -1 }); + expect(idx).toBeDefined(); + }); + + it("has a compound index for draft prompts", () => { + const idx = findIndex(promptIndexes, { owner: 1, listingStatus: 1, updatedAt: -1 }); + expect(idx).toBeDefined(); + }); + + it("has a compound index for saved prompts", () => { + const idx = findIndex(promptIndexes, { savedPrompts: 1, createdAt: -1 }); + expect(idx).toBeDefined(); + }); + + it("has a compound index for preview stats queries", () => { + const idx = findIndex(promptIndexes, { owner: 1, previewCount: -1 }); + expect(idx).toBeDefined(); + }); + + it("has a compound index for onChainId + isActive lookups", () => { + const idx = findIndex(promptIndexes, { onChainId: 1, isActive: 1 }); + expect(idx).toBeDefined(); + }); + + it("keeps the existing title index", () => { + const idx = findIndex(promptIndexes, { title: 1 }); + expect(idx).toBeDefined(); + }); + + it("keeps the existing single-field indexes (title, similarityFlag, onChainId, isActive, listingStatus)", () => { + for (const field of ["title", "similarityFlag", "onChainId", "isActive", "listingStatus"]) { + const idx = findIndex(promptIndexes, { [field]: 1 }); + expect(idx).toBeDefined(); + } + }); +}); + +describe("MarketplaceTransaction model indexes", () => { + const txIndexes = getIndexes(MarketplaceTransaction.schema); + + it("has a compound index for buyer transaction history queries", () => { + const idx = findIndex(txIndexes, { buyerWallet: 1, occurredAt: -1 }); + expect(idx).toBeDefined(); + }); + + it("has a compound index for creator transaction history queries", () => { + const idx = findIndex(txIndexes, { creatorWallet: 1, occurredAt: -1 }); + expect(idx).toBeDefined(); + }); + + it("has a compound index for prompt-specific transaction queries", () => { + const idx = findIndex(txIndexes, { promptOnChainId: 1, occurredAt: -1 }); + expect(idx).toBeDefined(); + }); + + it("keeps the unique sparse compound index for deduplication", () => { + const idx = findIndex(txIndexes, { buyerWallet: 1, promptOnChainId: 1, txHash: 1 }); + expect(idx).toBeDefined(); + expect(idx?.options?.unique).toBe(true); + expect(idx?.options?.sparse).toBe(true); + }); + + it("keeps the existing single-field indexes", () => { + for (const field of ["promptMongoId", "buyerWallet", "creatorWallet", "txHash", "occurredAt"]) { + const idx = findIndex(txIndexes, { [field]: 1 }); + expect(idx).toBeDefined(); + } + }); +}); + +describe("Purchase model indexes", () => { + const purchaseIndexes = getIndexes(Purchase.schema); + + it("has a compound index for buyer purchase history", () => { + const idx = findIndex(purchaseIndexes, { buyerWallet: 1, createdAt: -1 }); + expect(idx).toBeDefined(); + }); + + it("has a compound index for prompt purchase history", () => { + const idx = findIndex(purchaseIndexes, { promptId: 1, createdAt: -1 }); + expect(idx).toBeDefined(); + }); + + it("keeps the existing unique compound index", () => { + const idx = findIndex(purchaseIndexes, { promptId: 1, buyerWallet: 1 }); + expect(idx).toBeDefined(); + }); + + it("keeps the existing single-field indexes", () => { + for (const field of ["promptId", "buyerWallet", "saved"]) { + const idx = findIndex(purchaseIndexes, { [field]: 1 }); + expect(idx).toBeDefined(); + } + }); +}); diff --git a/server/src/tests/prePublishReview.test.ts b/server/src/tests/prePublishReview.test.ts index b8825a8..4f81b7a 100644 --- a/server/src/tests/prePublishReview.test.ts +++ b/server/src/tests/prePublishReview.test.ts @@ -1,5 +1,3 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; - describe("Pre-Publish Review Workflow", () => { describe("Submit for Review", () => { it("should transition draft to ready status", async () => { @@ -11,7 +9,7 @@ describe("Pre-Publish Review Workflow", () => { price: 10, category: "Programming", reviewChecklist: {}, - save: vi.fn(), + save: jest.fn(), }; mockPrompt.reviewChecklist = { @@ -37,7 +35,7 @@ describe("Pre-Publish Review Workflow", () => { it("should reject empty content", () => { const content = ""; - const isValid = content && content.length >= 10; + const isValid = Boolean(content) && content.length >= 10; expect(isValid).toBe(false); }); diff --git a/server/src/tests/tagManagement.test.ts b/server/src/tests/tagManagement.test.ts index d3c97f1..791e64d 100644 --- a/server/src/tests/tagManagement.test.ts +++ b/server/src/tests/tagManagement.test.ts @@ -1,5 +1,3 @@ -import { describe, it, expect } from "vitest"; - describe("Tag Management", () => { describe("Add Tags", () => { it("should add new tags to prompt", () => { diff --git a/src/lib/api/payloadVersion.js b/src/lib/api/payloadVersion.js new file mode 100644 index 0000000..4ca4ed6 --- /dev/null +++ b/src/lib/api/payloadVersion.js @@ -0,0 +1,86 @@ +/** + * Payload versioning for all public API responses and webhook deliveries. + * + * Every response envelope and every outbound webhook payload carries a stable + * version string so consumers can branch on it without relying on field + * presence heuristics. + * + * Versioning scheme + * ----------------- + * - Format: "YYYY-MM-DD" calendar-date strings. + * - A new date is introduced only when a field is removed, renamed, or its + * semantic meaning changes in a breaking way. Additive changes (new optional + * fields) do NOT require a new version. + * - `CURRENT_API_VERSION` is the version returned when the caller does not send + * an `Accept-Version` header, or sends the special value "latest". + * - `SUPPORTED_API_VERSIONS` lists every version the server still honours. + * Versions outside this set are rejected with 400 / UNSUPPORTED_VERSION. + * - `WEBHOOK_SCHEMA_VERSION` tracks the shape of outbound webhook payloads + * independently; it follows the same date scheme. + * + * Backward compatibility + * ---------------------- + * - v1 ("2024-01-01") is the implicit baseline that existed before this module + * was introduced. It is listed in SUPPORTED_API_VERSIONS so that callers + * pinned to it receive the same response shape they always did (the + * `apiVersion` field is the only addition, which is purely additive). + * - Removing a version from SUPPORTED_API_VERSIONS constitutes a breaking + * change and MUST be documented in docs/payload-versioning.md with at least + * 90 days notice. + */ +// ── Constants ───────────────────────────────────────────────────────────────── +/** The version returned to callers that do not specify Accept-Version. */ +export const CURRENT_API_VERSION = "2025-01-01"; +/** + * All API versions the server currently accepts. + * Ordered from newest to oldest for fast iteration. + */ +export const SUPPORTED_API_VERSIONS = [ + "2025-01-01", + "2024-01-01", // baseline — equivalent to pre-versioning behaviour +]; +/** Schema version embedded in every outbound webhook payload. */ +export const WEBHOOK_SCHEMA_VERSION = "2025-01-01"; +// ── Header name ─────────────────────────────────────────────────────────────── +/** HTTP request header clients use to pin a specific API version. */ +export const ACCEPT_VERSION_HEADER = "accept-version"; +// ── Helpers ─────────────────────────────────────────────────────────────────── +/** + * Resolve which API version to use for the current request. + * + * Resolution order: + * 1. Value of the `Accept-Version` request header (case-insensitive). + * 2. The literal string "latest" → maps to CURRENT_API_VERSION. + * 3. Missing header → CURRENT_API_VERSION. + * + * Returns `null` when the requested version is non-empty and not supported, + * so the caller can respond with 400 / UNSUPPORTED_VERSION. + */ +export function resolveApiVersion(headers) { + const raw = headers[ACCEPT_VERSION_HEADER]; + const requested = Array.isArray(raw) ? raw[0] : raw; + if (!requested || requested.trim() === "" || requested.trim() === "latest") { + return CURRENT_API_VERSION; + } + const normalised = requested.trim(); + if (SUPPORTED_API_VERSIONS.includes(normalised)) { + return normalised; + } + return null; // unsupported +} +/** + * Stamp a response body with the resolved API version. + * The original object is not mutated; a new shallow copy is returned. + * + * @example + * res.status(200).json(withVersion({ promptId: "42", plaintext: "..." }, version)); + */ +export function withVersion(body, version = CURRENT_API_VERSION) { + return { apiVersion: version, ...body }; +} +/** + * Error code surfaced when a caller requests a version the server no longer supports. + * Kept here rather than in errorCodes.ts to avoid a circular import; the + * string is intentionally identical to the ErrorCode pattern used elsewhere. + */ +export const UNSUPPORTED_VERSION_CODE = "UNSUPPORTED_VERSION";