597 lines
21 KiB
TypeScript
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");
|
|
});
|
|
});
|