Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions backend/src/__tests__/certificate.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Integration test — GET /api/v1/certificates
*
* Mounts the real certificate router through Express and drives it with
* supertest. Only the Certificate Mongoose model is mocked, so the route
* validation and controller → service path behave exactly as in production.
*/
import request from "supertest";
import express from "express";
import certificateRoutes from "../routes/certificate.routes";
import { globalErrorHandler } from "../middlewares/errorHandler";
import Certificate from "../models/Certificate.model";

jest.mock("../models/Certificate.model");

const CertificateMock = Certificate as unknown as {
find: jest.Mock;
countDocuments: jest.Mock;
};

function mockQueryChain(docs: unknown[], total: number) {
const lean = jest.fn().mockResolvedValue(docs);
const limit = jest.fn().mockReturnValue({ lean });
const skip = jest.fn().mockReturnValue({ limit });
const sort = jest.fn().mockReturnValue({ skip });
const populateManifest = jest.fn().mockReturnValue({ sort });
const populateAsset = jest.fn().mockReturnValue({ populate: populateManifest });
CertificateMock.find.mockReturnValue({ populate: populateAsset });
CertificateMock.countDocuments.mockResolvedValue(total);
}

function buildTestApp() {
const app = express();
app.use(express.json());
app.use("/api/v1/certificates", certificateRoutes);
app.use(globalErrorHandler);
return app;
}

const sampleDoc = {
_id: "665f1e2b3f4a5b6c7d8e9f01",
certificateId: "cert-onchain-0001",
transactionHash: "deadbeefcafe0011",
contractAddress: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
stellarNetwork: "testnet",
mintedAt: new Date("2026-07-10T09:30:00.000Z"),
assetId: {
fileName: "Aurora — Limited Edition Album",
mimeType: "audio/mpeg",
},
manifestId: {
contentHash: "0xa1b2c3d4e5f6",
creator: "GBVBK2TX7QHEQNIMUPBVPZ7EONL52TWKQ7OXFDJPAJPYGNZFACUQBXP",
metadata: { description: "Limited release album verified on-chain" },
},
};

describe("GET /api/v1/certificates", () => {
const app = buildTestApp();

beforeEach(() => {
jest.clearAllMocks();
mockQueryChain([sampleDoc], 1);
});

it("serves the public global index without a creatorId", async () => {
const res = await request(app).get("/api/v1/certificates");

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.total).toBe(1);
expect(res.body.data.certificates[0].certificateId).toBe("cert-onchain-0001");
// Global mode: no creator filter applied.
expect(CertificateMock.find).toHaveBeenCalledWith({});
});

it("applies the search filter the frontend sends", async () => {
const res = await request(app)
.get("/api/v1/certificates")
.query({ search: "aurora", limit: "50", skip: "0" });

expect(res.status).toBe(200);
const filter = CertificateMock.find.mock.calls[0][0];
expect(filter.$and).toHaveLength(1);
expect(filter.$and[0].$or[0].certificateId.$regex).toBe("aurora");
expect(filter.$and[0].$or[0].certificateId.$options).toBe("i");
});

it("still serves the legacy per-creator listing", async () => {
const res = await request(app)
.get("/api/v1/certificates")
.query({ creatorId: "665f1e2b3f4a5b6c7d8e9f01" });

expect(res.status).toBe(200);
const filter = CertificateMock.find.mock.calls[0][0];
expect(String(filter.$and[0].creatorId)).toBe("665f1e2b3f4a5b6c7d8e9f01");
});

it("rejects an invalid creatorId with a 400 envelope", async () => {
const res = await request(app)
.get("/api/v1/certificates")
.query({ creatorId: "not-valid" });

expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
expect(res.body.error).toBe("Invalid query parameters");
});

it("rejects an out-of-range limit", async () => {
const res = await request(app)
.get("/api/v1/certificates")
.query({ limit: "250" });

expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
});
76 changes: 76 additions & 0 deletions backend/src/__tests__/certificate.routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { listCertificatesQuerySchema } from "../routes/certificate.routes";

describe("listCertificatesQuerySchema", () => {
it("accepts a bare request for the public global index", () => {
const result = listCertificatesQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.creatorId).toBeUndefined();
expect(result.data.search).toBeUndefined();
expect(result.data.limit).toBe(20);
expect(result.data.skip).toBe(0);
}
});

it("accepts the global index query used by the search page (search+limit+skip)", () => {
const result = listCertificatesQuerySchema.safeParse({
search: "aurora",
limit: "50",
skip: "0",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.search).toBe("aurora");
expect(result.data.limit).toBe(50);
}
});

it("keeps supporting the legacy per-creator query", () => {
const result = listCertificatesQuerySchema.safeParse({
creatorId: "665f1e2b3f4a5b6c7d8e9f01",
});
expect(result.success).toBe(true);
});

it("accepts search + creatorId combined", () => {
const result = listCertificatesQuerySchema.safeParse({
creatorId: "665f1e2b3f4a5b6c7d8e9f01",
search: "cert.*(1)",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.search).toBe("cert.*(1)");
}
});

it("trims whitespace around the search term", () => {
const result = listCertificatesQuerySchema.safeParse({
search: " aurora ",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.search).toBe("aurora");
}
});

it("rejects a malformed creatorId", () => {
expect(
listCertificatesQuerySchema.safeParse({ creatorId: "bad-id" }).success,
).toBe(false);
});

it("rejects limit above 100 and negative skip", () => {
expect(listCertificatesQuerySchema.safeParse({ limit: "999" }).success).toBe(
false,
);
expect(listCertificatesQuerySchema.safeParse({ skip: "-1" }).success).toBe(
false,
);
});

it("rejects an over-long search term", () => {
expect(
listCertificatesQuerySchema.safeParse({ search: "x".repeat(300) }).success,
).toBe(false);
});
});
128 changes: 128 additions & 0 deletions backend/src/__tests__/certificate.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import mongoose from "mongoose";
import { certificateService } from "../services/certificate.service";
import Certificate from "../models/Certificate.model";
import type { ListCertificatesQuery } from "../types/certificate.types";

jest.mock("../models/Certificate.model");

const CertificateMock = Certificate as unknown as {
find: jest.Mock;
countDocuments: jest.Mock;
};

/** Wire the Mongoose query chain used by CertificateService.listCertificates. */
function mockQueryChain(docs: unknown[], total: number) {
const lean = jest.fn().mockResolvedValue(docs);
const limit = jest.fn().mockReturnValue({ lean });
const skip = jest.fn().mockReturnValue({ limit });
const sort = jest.fn().mockReturnValue({ skip });
const populateManifest = jest.fn().mockReturnValue({ sort });
const populateAsset = jest.fn().mockReturnValue({ populate: populateManifest });
CertificateMock.find.mockReturnValue({ populate: populateAsset });
CertificateMock.countDocuments.mockResolvedValue(total);
}

function baseQuery(partial: Partial<ListCertificatesQuery>): ListCertificatesQuery {
return { limit: 20, skip: 0, ...partial };
}

describe("CertificateService.listCertificates", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("queries the global index when creatorId is omitted", async () => {
mockQueryChain([{ certificateId: "cert-1" }], 1);

const result = await certificateService.listCertificates(baseQuery({}));

expect(CertificateMock.find).toHaveBeenCalledWith({});
expect(result.total).toBe(1);
expect(result.certificates).toHaveLength(1);
});

it("filters by creatorId when supplied", async () => {
mockQueryChain([], 0);
const creatorId = new mongoose.Types.ObjectId().toHexString();

await certificateService.listCertificates(baseQuery({ creatorId }));

const filter = CertificateMock.find.mock.calls[0][0];
expect(filter.$and).toHaveLength(1);
expect(String(filter.$and[0].creatorId)).toBe(creatorId);
});

it("builds a case-insensitive $or search across on-chain identifiers", async () => {
mockQueryChain([], 0);

await certificateService.listCertificates(baseQuery({ search: "cert-ABC" }));

const filter = CertificateMock.find.mock.calls[0][0];
const searchCondition = filter.$and.find(
(c: Record<string, unknown>) => "$or" in c,
) as { $or: Record<string, { $regex: string; $options: string }>[] };

expect(searchCondition.$or.map((entry) => Object.keys(entry)[0])).toEqual([
"certificateId",
"transactionHash",
"contractAddress",
]);
expect(searchCondition.$or[0].certificateId.$regex).toBe("cert-ABC");
expect(searchCondition.$or[0].certificateId.$options).toBe("i");
});

it("escapes regex metacharacters in the search term", async () => {
mockQueryChain([], 0);

await certificateService.listCertificates(baseQuery({ search: "cert.*(1)" }));

const filter = CertificateMock.find.mock.calls[0][0];
const searchCondition = filter.$and.find(
(c: Record<string, unknown>) => "$or" in c,
) as { $or: Record<string, { $regex: string }>[] };

expect(searchCondition.$or[0].certificateId.$regex).toBe(
"cert\\.\\*\\(1\\)",
);
});

it("combines creatorId and search under $and", async () => {
mockQueryChain([{ certificateId: "cert-9" }], 1);
const creatorId = new mongoose.Types.ObjectId().toHexString();

await certificateService.listCertificates(
baseQuery({ creatorId, search: "cert-9" }),
);

const filter = CertificateMock.find.mock.calls[0][0];
expect(filter.$and).toHaveLength(2);
expect(CertificateMock.countDocuments).toHaveBeenCalledWith(filter);
});

it("rejects an invalid creatorId", async () => {
await expect(
certificateService.listCertificates(baseQuery({ creatorId: "not-an-id" })),
).rejects.toMatchObject({ code: "INVALID_CREATOR_ID" });
});

it("rejects out-of-range pagination", async () => {
await expect(
certificateService.listCertificates(baseQuery({ limit: 0 })),
).rejects.toMatchObject({ code: "INVALID_PAGINATION" });
await expect(
certificateService.listCertificates(baseQuery({ skip: -1 })),
).rejects.toMatchObject({ code: "INVALID_PAGINATION" });
});

it("populates asset and manifest relations for the frontend", async () => {
mockQueryChain([], 0);

await certificateService.listCertificates(baseQuery({}));

const findResult = CertificateMock.find.mock.results[0].value;
expect(findResult.populate).toHaveBeenCalledWith(
"assetId",
"fileName mimeType storageReferenceId",
);
});
});
27 changes: 20 additions & 7 deletions backend/src/routes/certificate.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
* Certificate Routes – request validation schemas and route definitions.
*
* Endpoints:
* GET /api/v1/certificates?creatorId=<ObjectId>&limit=20&skip=0
* Returns a paginated list of certificates owned by the given user.
* GET /api/v1/certificates?creatorId=<ObjectId>&search=<term>&limit=20&skip=0
* Returns a paginated list of certificates. `creatorId` narrows the list
* to a single owner; when omitted the endpoint exposes the public global
* certificate index. `search` matches the on-chain certificateId,
* transactionHash and contractAddress (case-insensitive).
*
* All Zod schemas are co-located with the routes that use them.
*/
Expand All @@ -14,12 +17,19 @@ import { StatusCodes } from "http-status-codes";
import { certificateController } from "../controllers/certificate.controller";

// ---------------------------------------------------------------------------
// Zod schema – query parameters for the certificate list endpoint
// Zod schema – query parameters for the certificate list endpoint.
// Exported so the validation contract can be unit-tested directly.
// ---------------------------------------------------------------------------
const listCertificatesQuerySchema = z.object({
export const listCertificatesQuerySchema = z.object({
creatorId: z
.string()
.regex(/^[a-f\d]{24}$/i, "creatorId must be a valid MongoDB ObjectId"),
.regex(/^[a-f\d]{24}$/i, "creatorId must be a valid MongoDB ObjectId")
.optional(),
search: z
.string()
.trim()
.max(256, "search must be at most 256 characters")
.optional(),
limit: z
.string()
.optional()
Expand Down Expand Up @@ -72,10 +82,13 @@ function validateListCertificatesQuery(
const router = Router();

/**
* GET /api/v1/certificates?creatorId=<ObjectId>&limit=20&skip=0
* GET /api/v1/certificates?creatorId=<ObjectId>&search=<term>&limit=20&skip=0
*
* Query parameters:
* - creatorId (required) – MongoDB ObjectId of the certificate owner.
* - creatorId (optional) – MongoDB ObjectId of the certificate owner. When
* omitted, the public global certificate index is returned.
* - search (optional, ≤256 chars) – matches certificateId, transactionHash
* and contractAddress (case-insensitive).
* - limit (optional, 1–100, default 20) – page size.
* - skip (optional, ≥0, default 0) – offset for pagination.
*
Expand Down
Loading
Loading