feat: 实现公平捞瓶和领取租约
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
/* 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";
|
||||
|
||||
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("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 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.every((x) => x.status === 201)).toBe(true);
|
||||
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("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 Promise.all(
|
||||
Array.from({ length: 22 }, (_, i) => bottle(author.id, `bottle-${i}`)),
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user