Files
plp/apps/api/src/match/match.e2e-spec.ts
T
2026-09-15 21:13:28 +08:00

597 lines
21 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return */
import "reflect-metadata";
import { type INestApplication } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import { PrismaClient } from "@prisma/client";
import { randomUUID } from "node:crypto";
import request from "supertest";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { assertSafeTestDatabaseUrl } from "../../../../prisma/database-safety.js";
import { AppModule } from "../app.module.js";
import { TokenService } from "../auth/token.service.js";
import { configureApp } from "../main.js";
import { CandidateRepository } from "./candidate.repository.js";
process.env.NODE_ENV = "test";
process.env.WEB_ORIGIN = "http://localhost:3000";
process.env.PHONE_ENCRYPTION_KEY =
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
process.env.VERIFICATION_CODE_HMAC_KEY =
"test-code-hmac-key-with-at-least-32-bytes";
process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes";
process.env.REFRESH_TOKEN_HMAC_KEY =
"test-refresh-hmac-key-with-at-least-32-bytes";
process.env.LEASE_TOKEN_HMAC_KEY = "test-lease-hmac-key-with-at-least-32-bytes";
process.env.LEASE_TOKEN_ENCRYPTION_KEY =
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=";
const prisma = new PrismaClient();
describe("match API with real PostgreSQL", () => {
let app: INestApplication;
beforeAll(async () => {
assertSafeTestDatabaseUrl(process.env.DATABASE_URL ?? "");
await prisma.$connect();
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
});
beforeEach(async () => {
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`);
});
afterAll(async () => {
await app?.close();
await prisma.$disconnect();
});
async function actor(
deviceId: string,
profileStatus: "APPROVED" | "REVIEWING" = "APPROVED",
) {
const account = await prisma.account.create({
data: {
phoneCiphertext: Buffer.from("cipher"),
phoneHmac: randomUUID(),
anonymousProfile: {
create: {
nickname: `anon-${deviceId}`,
avatarColor: "#123456",
bio: `bio-${deviceId}`,
reviewStatus: profileStatus,
},
},
},
include: { anonymousProfile: true },
});
const session = await prisma.session.create({
data: {
accountId: account.id,
refreshTokenHash: randomUUID(),
deviceId,
expiresAt: new Date(Date.now() + 60_000),
tokenFamily: randomUUID(),
},
});
const token = app.get(TokenService).issueAccess({
sub: account.id,
session_id: session.id,
device_id: deviceId,
scopes: ["user"],
token_version: 0,
});
return {
id: account.id,
authorization: `Bearer ${token}`,
profile: account.anonymousProfile!,
};
}
async function bottle(authorId: string, contentText = "message in bottle") {
return prisma.bottle.create({
data: {
authorId,
clientRequestId: randomUUID(),
contentText,
reviewStatus: "APPROVED",
poolStatus: "IN_POOL",
approvedAt: new Date(),
},
});
}
const pick = (authorization: string, key: string = randomUUID()) =>
request(app.getHttpServer())
.post("/api/v1/bottles/pick")
.set("Authorization", authorization)
.set("Idempotency-Key", key)
.send({});
const returnBottle = (
authorization: string,
bottleId: string,
leaseId: string,
token: string,
) =>
request(app.getHttpServer())
.post(`/api/v1/bottles/${bottleId}/return`)
.set("Authorization", authorization)
.send({ leaseId, token });
it("requires authentication for both match endpoints", async () => {
await request(app.getHttpServer())
.post("/api/v1/bottles/pick")
.set("Idempotency-Key", randomUUID())
.send({})
.expect(401);
await request(app.getHttpServer())
.post(`/api/v1/bottles/${randomUUID()}/return`)
.send({ leaseId: randomUUID(), token: "x".repeat(43) })
.expect(401);
});
it("uses a bounded UUID keyset pivot so every region beyond the oldest 32 is reachable", async () => {
const author = await actor("author-device");
const picker = await actor("picker-device");
const ids = Array.from({ length: 96 }, (_, index) => {
const prefix = index.toString(16).padStart(8, "0");
return `${prefix}-0000-4000-8000-000000000000`;
});
await prisma.bottle.createMany({
data: ids.map((id, index) => ({
id,
authorId: author.id,
clientRequestId: `pivot-${index}`,
contentText: `pivot-${index}`,
reviewStatus: "APPROVED" as const,
poolStatus: "IN_POOL" as const,
approvedAt: new Date(),
})),
});
const repository = app.get(CandidateRepository);
const front = await repository.findBatch(prisma, picker.id, 32, ids[0]);
const middle = await repository.findBatch(prisma, picker.id, 32, ids[40]);
const back = await repository.findBatch(prisma, picker.id, 32, ids[88]);
expect(front).toHaveLength(32);
expect(middle[0]?.id).toBe(ids[40]);
expect(back.map(({ id }) => id)).toContain(ids[95]);
expect(back.map(({ id }) => id)).toContain(ids[0]);
expect(new Set(back.map(({ id }) => id)).size).toBe(back.length);
expect(middle.map(({ id }) => id)).not.toEqual(front.map(({ id }) => id));
});
it("picks an approved in-pool bottle and exposes only an anonymous public snapshot", async () => {
const author = await actor("author-device");
const picker = await actor("picker-device");
const source = await bottle(author.id, "hello from sea");
const response = await pick(picker.authorization).expect(201);
expect(response.body.data).toMatchObject({
bottle: { id: source.id, contentText: "hello from sea" },
author: {
publicId: author.profile.publicId,
nickname: author.profile.nickname,
avatarColor: author.profile.avatarColor,
bio: author.profile.bio,
},
lease: {
id: expect.any(String),
token: expect.stringMatching(/^[A-Za-z0-9_-]{32,}$/),
expiresAt: expect.any(String),
},
});
expect(JSON.stringify(response.body.data)).not.toContain(author.id);
expect(JSON.stringify(response.body.data)).not.toContain("phone");
expect(
await prisma.bottle.findUniqueOrThrow({ where: { id: source.id } }),
).toMatchObject({ poolStatus: "LEASED", version: 2 });
expect(
await prisma.bottlePickHistory.count({
where: { bottleId: source.id, pickerId: picker.id },
}),
).toBe(1);
expect(
(
await prisma.dailyUsage.findFirstOrThrow({
where: { accountId: picker.id },
})
).bottlesPicked,
).toBe(1);
});
it("excludes own, history, both block directions, unapproved profile and unavailable bottle states", async () => {
const picker = await actor("picker-device");
const own = await bottle(picker.id, "own");
const historyAuthor = await actor("history-author");
const historical = await bottle(historyAuthor.id, "history");
await prisma.bottlePickHistory.create({
data: { bottleId: historical.id, pickerId: picker.id },
});
const blockedByPicker = await actor("blocked-one");
await bottle(blockedByPicker.id, "blocked outgoing");
await prisma.block.create({
data: { blockerId: picker.id, blockedId: blockedByPicker.id },
});
const blocksPicker = await actor("blocked-two");
await bottle(blocksPicker.id, "blocked incoming");
await prisma.block.create({
data: { blockerId: blocksPicker.id, blockedId: picker.id },
});
const pendingProfile = await actor("pending-profile", "REVIEWING");
await bottle(pendingProfile.id, "pending profile");
const validAuthor = await actor("valid-author");
const valid = await bottle(validAuthor.id, "valid");
await prisma.bottle.create({
data: {
authorId: validAuthor.id,
clientRequestId: randomUUID(),
contentText: "reviewing",
reviewStatus: "REVIEWING",
poolStatus: "IN_POOL",
},
});
const closed = await bottle(validAuthor.id, "closed");
await prisma.bottle.update({
where: { id: closed.id },
data: { poolStatus: "CLOSED" },
});
const leased = await bottle(validAuthor.id, "leased");
await prisma.bottle.update({
where: { id: leased.id },
data: { poolStatus: "LEASED" },
});
const activeLease = await bottle(validAuthor.id, "active lease id");
const leaseId = randomUUID();
await prisma.$transaction(async (tx) => {
await tx.bottle.update({
where: { id: activeLease.id },
data: { activeLeaseId: leaseId },
});
await tx.bottlePickLease.create({
data: {
id: leaseId,
bottleId: activeLease.id,
pickerId: picker.id,
leaseTokenHash: randomUUID(),
leaseTokenCiphertext: Buffer.from("ciphertext"),
expiresAt: new Date(Date.now() + 60_000),
},
});
});
const picked = await pick(picker.authorization).expect(201);
expect(picked.body.data.bottle.id).toBe(valid.id);
expect(picked.body.data.bottle.id).not.toBe(own.id);
});
it("returns pool empty without consuming quota", async () => {
const picker = await actor("picker-device");
const response = await pick(picker.authorization).expect(404);
expect(response.body.code).toBe("BOTTLE_POOL_EMPTY");
expect(
await prisma.dailyUsage.count({ where: { accountId: picker.id } }),
).toBe(0);
});
it("deduplicates concurrent pick retries into one lease and one quota use", async () => {
const author = await actor("author-device");
const picker = await actor("picker-device");
await bottle(author.id);
const key = randomUUID();
const results = await Promise.all(
Array.from({ length: 8 }, () => pick(picker.authorization, key)),
);
expect(results.map((x) => [x.status, x.body.code])).toEqual(
Array.from({ length: 8 }, () => [201, "OK"]),
);
expect(new Set(results.map((x) => x.body.data.lease.id))).toHaveLength(1);
expect(new Set(results.map((x) => x.body.data.lease.token))).toHaveLength(
1,
);
expect(await prisma.bottlePickLease.count()).toBe(1);
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesPicked).toBe(1);
const token = results[0]!.body.data.lease.token as string;
const stored = await prisma.bottlePickLease.findFirstOrThrow();
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(stored.leaseTokenHash).not.toContain(token);
expect(
Buffer.from(stored.leaseTokenCiphertext).toString("utf8"),
).not.toContain(token);
});
it("fails closed with a stable response when an idempotent lease token cannot be decrypted", async () => {
const author = await actor("author-device");
const picker = await actor("picker-device");
await bottle(author.id);
const key = randomUUID();
const first = await pick(picker.authorization, key).expect(201);
await prisma.bottlePickLease.update({
where: { id: first.body.data.lease.id },
data: { leaseTokenCiphertext: Buffer.from("corrupt") },
});
const retry = await pick(picker.authorization, key).expect(503);
expect(retry.body).toMatchObject({
code: "SERVICE_UNAVAILABLE",
message: "Bottle pick temporarily unavailable",
});
expect(JSON.stringify(retry.body)).not.toContain("cipher");
expect(await prisma.bottlePickLease.count()).toBe(1);
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesPicked).toBe(1);
});
it.each(["RETURNED", "EXPIRED"] as const)(
"returns the original pick response when the idempotent lease is %s",
async (status) => {
const author = await actor("author-device");
const picker = await actor("picker-device");
const source = await bottle(author.id);
const key = randomUUID();
const first = await pick(picker.authorization, key).expect(201);
if (status === "RETURNED") {
await returnBottle(
picker.authorization,
source.id,
first.body.data.lease.id,
first.body.data.lease.token,
).expect(201);
} else {
await prisma.bottlePickLease.update({
where: { id: first.body.data.lease.id },
data: { status: "EXPIRED", endedAt: new Date() },
});
}
const retry = await pick(picker.authorization, key).expect(201);
expect(retry.body.data).toEqual(first.body.data);
expect(await prisma.bottlePickLease.count()).toBe(1);
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesPicked).toBe(
1,
);
},
);
it("issues independent random opaque tokens for different requests", async () => {
const author = await actor("author-device");
const picker = await actor("picker-device");
await bottle(author.id, "one");
await bottle(author.id, "two");
const first = (await pick(picker.authorization, "request-one").expect(201))
.body.data.lease.token;
const second = (await pick(picker.authorization, "request-two").expect(201))
.body.data.lease.token;
expect(first).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(second).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(second).not.toBe(first);
});
it("rejects malformed return UUIDs and non-canonical lease tokens at the DTO boundary", async () => {
const picker = await actor("picker-device");
await returnBottle(
picker.authorization,
"not-a-uuid",
randomUUID(),
"x".repeat(43),
).expect(400);
await returnBottle(
picker.authorization,
randomUUID(),
"not-a-uuid",
"x".repeat(43),
).expect(400);
await returnBottle(
picker.authorization,
randomUUID(),
randomUUID(),
"x".repeat(42),
).expect(400);
});
it("rejects malformed idempotency keys and canonicalizes surrounding whitespace", async () => {
const author = await actor("author-device");
const picker = await actor("picker-device");
await bottle(author.id);
await pick(picker.authorization, "bad key").expect(400);
const first = await pick(picker.authorization, " stable-key ").expect(201);
const retry = await pick(picker.authorization, "stable-key").expect(201);
expect(retry.body.data.lease).toEqual(first.body.data.lease);
});
it("allows only one of two users to concurrently lease the sole bottle", async () => {
const author = await actor("author-device");
const a = await actor("picker-a-device");
const b = await actor("picker-b-device");
const source = await bottle(author.id);
const results = await Promise.all([
pick(a.authorization),
pick(b.authorization),
]);
expect(results.filter((x) => x.status === 201)).toHaveLength(1);
expect(
results.filter(
(x) => x.status === 404 && x.body.code === "BOTTLE_POOL_EMPTY",
),
).toHaveLength(1);
expect(
await prisma.bottlePickLease.count({
where: { bottleId: source.id, status: "ACTIVE" },
}),
).toBe(1);
});
it("enforces twenty successful picks atomically and resets on another UTC+8 day", async () => {
const author = await actor("author-device");
const picker = await actor("picker-device");
await prisma.bottle.createMany({
data: Array.from({ length: 22 }, (_, i) => ({
authorId: author.id,
clientRequestId: randomUUID(),
contentText: `bottle-${i}`,
reviewStatus: "APPROVED",
poolStatus: "IN_POOL",
approvedAt: new Date(),
})),
});
const results = await Promise.all(
Array.from({ length: 21 }, () => pick(picker.authorization)),
);
expect(results.filter((x) => x.status === 201)).toHaveLength(20);
expect(
results.filter(
(x) => x.status === 429 && x.body.code === "BOTTLE_DAILY_LIMIT",
),
).toHaveLength(1);
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesPicked).toBe(20);
await prisma.dailyUsage.updateMany({
data: { usageDate: new Date("2020-01-01T00:00:00.000Z") },
});
await pick(picker.authorization).expect(201);
}, 15_000);
it("sustains twenty rounds of 21 concurrent distinct requests without picker-level serialization", async () => {
for (let round = 0; round < 20; round += 1) {
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`);
const author = await actor(`stress-author-${round}`);
const picker = await actor(`stress-picker-${round}`);
await prisma.bottle.createMany({
data: Array.from({ length: 96 }, (_, index) => ({
authorId: author.id,
clientRequestId: `stress-${round}-${index}`,
contentText: `stress-${round}-${index}`,
reviewStatus: "APPROVED" as const,
poolStatus: "IN_POOL" as const,
approvedAt: new Date(),
})),
});
const results = await Promise.all(
Array.from({ length: 21 }, (_, index) =>
pick(picker.authorization, `stress-key-${round}-${index}`),
),
);
expect(results.filter(({ status }) => status === 201)).toHaveLength(20);
expect(
results.filter(
({ status, body }) =>
status === 429 && body.code === "BOTTLE_DAILY_LIMIT",
),
).toHaveLength(1);
expect(results.filter(({ status }) => status >= 500)).toHaveLength(0);
expect(
await prisma.dailyUsage.findFirstOrThrow({
where: { accountId: picker.id },
}),
).toMatchObject({ bottlesPicked: 20 });
expect(
await prisma.bottlePickLease.count({ where: { pickerId: picker.id } }),
).toBe(20);
expect(
await prisma.bottlePickHistory.count({
where: { pickerId: picker.id },
}),
).toBe(20);
expect(
await prisma.bottlePickRequest.count({
where: { pickerId: picker.id },
}),
).toBe(20);
}
}, 120_000);
it.each(["SUSPENSION", "BAN"] as const)(
"rejects active %s sanction",
async (type) => {
const author = await actor("author-device");
const picker = await actor("picker-device");
await bottle(author.id);
await prisma.sanction.create({
data: { accountId: picker.id, type, reason: "policy" },
});
const response = await pick(picker.authorization).expect(403);
expect(response.body.code).toBe("ACCOUNT_SANCTIONED");
expect(await prisma.bottlePickLease.count()).toBe(0);
},
);
it("returns an active lease, is idempotent, and permanently preserves pick history", async () => {
const author = await actor("author-device");
const picker = await actor("picker-device");
const source = await bottle(author.id);
const picked = (await pick(picker.authorization).expect(201)).body.data;
const first = await returnBottle(
picker.authorization,
source.id,
picked.lease.id,
picked.lease.token,
).expect(201);
const retry = await returnBottle(
picker.authorization,
source.id,
picked.lease.id,
picked.lease.token,
).expect(201);
expect(first.body.data).toEqual({
bottleId: source.id,
leaseId: picked.lease.id,
status: "RETURNED",
});
expect(retry.body.data).toEqual(first.body.data);
expect(
await prisma.bottle.findUniqueOrThrow({ where: { id: source.id } }),
).toMatchObject({ poolStatus: "IN_POOL", activeLeaseId: null, version: 3 });
await pick(picker.authorization).expect(404);
});
it("hides another user's lease and rejects an invalid opaque token without mutation", async () => {
const author = await actor("author-device");
const picker = await actor("picker-device");
const stranger = await actor("stranger-device");
const source = await bottle(author.id);
const picked = (await pick(picker.authorization).expect(201)).body.data;
await returnBottle(
stranger.authorization,
source.id,
picked.lease.id,
picked.lease.token,
).expect(404);
await returnBottle(
picker.authorization,
source.id,
picked.lease.id,
"x".repeat(43),
).expect(404);
expect(
await prisma.bottlePickLease.findUniqueOrThrow({
where: { id: picked.lease.id },
}),
).toMatchObject({ status: "ACTIVE", endedAt: null });
expect(
(await prisma.bottle.findUniqueOrThrow({ where: { id: source.id } }))
.poolStatus,
).toBe("LEASED");
});
it("rejects returning an expired lease without releasing it in the API", async () => {
const author = await actor("author-device");
const picker = await actor("picker-device");
const source = await bottle(author.id);
const picked = (await pick(picker.authorization).expect(201)).body.data;
await prisma.bottlePickLease.update({
where: { id: picked.lease.id },
data: { expiresAt: new Date(Date.now() - 1) },
});
const response = await returnBottle(
picker.authorization,
source.id,
picked.lease.id,
picked.lease.token,
).expect(410);
expect(response.body.code).toBe("BOTTLE_LEASE_EXPIRED");
});
});