api-server/test/m-member-01.integration.spec.ts
wangdl a621bd169d
Some checks failed
Deploy API Server / build-and-unit (push) Successful in 41s
Deploy API Server / current-integration (push) Successful in 33s
Deploy API Server / backward-compat (push) Successful in 0s
Deploy API Server / deploy (push) Failing after 57s
feat: 文档清理 + RAG Worker 迁移 + 批量操作 + HTTP 日志 + 学习会话修复
- 删除根 docs/ 中的 audit/issue 文档
- 删除 api-server/docs/ 中的 16 个审核/里程碑文档
- rag-worker/ 从 api-server 迁出为独立项目
- 新增 admin 导入批量重新解析、按知识库筛选、后端排序
- 新增 source 软删除标记,防止已删 source 重新解析
- 修复 upload-url 500(重复 UploadSession 创建 + 缺 sizeBytes)
- 修复 loadContextByScope 读 chunks 而非依赖 textLength
- 修复 MaterialReadingProgress.create() increment 语法错误
- 新增 LoggingInterceptor 记录所有 HTTP 请求
- 新增学习会话批量删除接口
- enrichWithNames 增加 email 回退
- 全局异常过滤器和 name-resolver 增强

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-01 22:44:36 +08:00

599 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* M-MEMBER-01-CLOSE-05B v2: 真实 Service Integration
*
* 每项走真实 NestJS Service、MySQL、Redis、Prisma。
* Mock 仅 JWS Verifier 和网络Apple/LLM/COS其他全部真实。
*/
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../src/infrastructure/database/prisma.service';
import { AppStoreTransactionService } from '../src/modules/membership/apple/app-store-transaction.service';
import { AppStoreNotificationService } from '../src/modules/membership/apple/app-store-notification.service';
import { AppStoreJwsVerifier } from '../src/modules/membership/apple/app-store-jws-verifier';
import { EffectiveQuotaService } from '../src/modules/membership/effective-quota.service';
import { EffectiveMembershipService } from '../src/modules/membership/effective-membership.service';
import * as crypto from 'crypto';
// ─── Test IDs ─────────────────────────────────────────────
const TEST_PREFIX = `svc_${crypto.randomBytes(4).toString('hex')}`;
const USER_A = `${TEST_PREFIX}_a`;
const USER_B = `${TEST_PREFIX}_b`;
const USER_C = `${TEST_PREFIX}_c`;
const PLAN_FREE = `${TEST_PREFIX}_f`;
const PLAN_PREMIUM = `${TEST_PREFIX}_p`;
const PRODUCT_ID = `zhixi.test.premium.${TEST_PREFIX}`;
const QUOTA_USER = `${TEST_PREFIX}_qu`;
// ─── Module setup ─────────────────────────────────────────
let testingModule: TestingModule;
let prisma: PrismaService;
let transactionService: AppStoreTransactionService;
let notificationService: AppStoreNotificationService;
let quotaService: EffectiveQuotaService;
// Mock JWS verifier
const mockJwsVerifier = {
verify: jest.fn(),
getExpectedBundleId: jest.fn().mockReturnValue('cloud.longde.AIStudyApp'),
getExpectedEnvironment: jest.fn().mockReturnValue('Production'),
};
// Mock ConfigService
const mockConfig = {
get: jest.fn((key: string, def: any) => {
const m: Record<string, any> = {
'apple.bundleId': 'cloud.longde.AIStudyApp',
'apple.environment': 'Production',
};
return m[key] ?? def;
}),
};
beforeAll(async () => {
testingModule = await Test.createTestingModule({
providers: [
PrismaService,
EffectiveMembershipService,
EffectiveQuotaService,
AppStoreTransactionService,
AppStoreNotificationService,
{ provide: AppStoreJwsVerifier, useValue: mockJwsVerifier },
{ provide: ConfigService, useValue: mockConfig },
],
}).compile();
prisma = testingModule.get(PrismaService);
transactionService = testingModule.get(AppStoreTransactionService);
notificationService = testingModule.get(AppStoreNotificationService);
quotaService = testingModule.get(EffectiveQuotaService);
// Clean up ALL previous test data with svc_ prefix to avoid orphaned plan references
const oldUsers = await prisma.user.findMany({ where: { id: { startsWith: 'svc_' } }, select: { id: true } });
for (const u of oldUsers) {
await prisma.quotaUsage.deleteMany({ where: { userId: u.id } }).catch(() => {});
await prisma.appStoreTransaction.deleteMany({ where: { userId: u.id } }).catch(() => {});
await prisma.userMembership.deleteMany({ where: { userId: u.id } }).catch(() => {});
await prisma.user.deleteMany({ where: { id: u.id } }).catch(() => {});
}
await prisma.planQuota.deleteMany({ where: { planId: { startsWith: 'plan_limit1_' } } }).catch(() => {});
await prisma.planQuota.deleteMany({ where: { planId: { startsWith: 'svc_' } } }).catch(() => {});
await prisma.membershipPlan.deleteMany({ where: { id: { startsWith: 'plan_limit1_' } } }).catch(() => {});
await prisma.storeProduct.deleteMany({ where: { productId: { startsWith: 'zhixi.test.premium.svc_' } } }).catch(() => {});
await prisma.membershipPlan.deleteMany({ where: { id: { startsWith: 'svc_' } } }).catch(() => {});
await prisma.appStoreNotification.deleteMany({ where: { notificationUuid: { startsWith: 'fake_' } } }).catch(() => {});
}, 60_000);
afterAll(async () => {
// Cleanup test data
const ids = [USER_A, USER_B, USER_C, QUOTA_USER];
for (const uid of ids) {
await prisma.quotaUsage.deleteMany({ where: { userId: uid } }).catch(() => {});
await prisma.userMembership.deleteMany({ where: { userId: uid } }).catch(() => {});
await prisma.appStoreTransaction.deleteMany({ where: { userId: uid } }).catch(() => {});
await prisma.user.deleteMany({ where: { id: uid } }).catch(() => {});
}
// Cleanup plans/products created by this test
await prisma.planQuota.deleteMany({ where: { planId: { in: [PLAN_FREE, PLAN_PREMIUM] } } }).catch(() => {});
await prisma.storeProduct.deleteMany({ where: { productId: PRODUCT_ID } }).catch(() => {});
await prisma.membershipPlan.deleteMany({ where: { id: { in: [PLAN_FREE, PLAN_PREMIUM] } } }).catch(() => {});
await testingModule?.close();
}, 60_000);
// ─── Fixture helpers ──────────────────────────────────────
async function createTestUsers() {
const userA = await prisma.user.create({
data: {
id: USER_A, email: `${USER_A}@test.com`, role: 'USER', status: 'active',
appAccountToken: crypto.randomUUID(), updatedAt: new Date(),
},
});
const userB = await prisma.user.create({
data: {
id: USER_B, email: `${USER_B}@test.com`, role: 'USER', status: 'active',
appAccountToken: null, updatedAt: new Date(),
},
});
const userC = await prisma.user.create({
data: {
id: USER_C, email: `${USER_C}@test.com`, role: 'USER', status: 'active',
appAccountToken: crypto.randomUUID(), updatedAt: new Date(),
},
});
}
async function createPlansAndProducts() {
const now = new Date();
await prisma.membershipPlan.createMany({
data: [
{ id: PLAN_FREE, code: PLAN_FREE, name: 'Free Plan', isActive: true, updatedAt: now },
{ id: PLAN_PREMIUM, code: PLAN_PREMIUM, name: 'Premium Plan', isActive: true, updatedAt: now },
],
skipDuplicates: true,
});
await prisma.planQuota.createMany({
data: [
{ planId: PLAN_PREMIUM, quotaType: 'active_recall', limit: 5, unit: 'count', windowType: 'daily' },
{ planId: PLAN_PREMIUM, quotaType: 'feynman_evaluation', limit: 3, unit: 'count', windowType: 'daily' },
{ planId: PLAN_PREMIUM, quotaType: 'quiz_generation', limit: 2, unit: 'count', windowType: 'daily' },
],
});
await prisma.storeProduct.create({
data: { platform: 'apple', productId: PRODUCT_ID, planId: PLAN_PREMIUM, billingPeriod: 'monthly', isActive: true },
});
}
function makeJwsPayload(overrides: Record<string, any> = {}) {
return {
transactionId: `tx_${crypto.randomBytes(8).toString('hex')}`,
originalTransactionId: `orig_${crypto.randomBytes(8).toString('hex')}`,
bundleId: 'cloud.longde.AIStudyApp',
productId: PRODUCT_ID,
environment: 'Production',
appAccountToken: '',
purchaseDate: Date.now(),
originalPurchaseDate: Date.now() - 86400000,
expiresDate: Date.now() + 86400000 * 30,
...overrides,
};
}
function makeNotifPayload(overrides: Record<string, any> = {}) {
const uuid = crypto.randomUUID();
return {
notificationUUID: uuid,
notificationType: 'SUBSCRIBED',
subtype: null,
data: {
appAccountToken: '',
bundleId: 'cloud.longde.AIStudyApp',
environment: 'Production',
signedDate: Date.now(),
...(overrides.data || {}),
},
...overrides,
};
}
// ─── ═══════════════════════════════════════════════════ ───
// 1. Transaction Integration
// ─── ═══════════════════════════════════════════════════ ───
describe('1. Transaction (真实 Service)', () => {
beforeAll(async () => {
await createTestUsers();
await createPlansAndProducts();
});
beforeEach(() => {
mockJwsVerifier.verify.mockReset();
});
describe('transactionId 幂等', () => {
it('串行 2 次同 transactionId: 第 1 次 created, 第 2 次 existing', async () => {
const appAccountToken = crypto.randomUUID();
await prisma.user.update({ where: { id: USER_A }, data: { appAccountToken } });
const txPayload = makeJwsPayload({ appAccountToken });
mockJwsVerifier.verify.mockResolvedValue(txPayload);
const r1 = await transactionService.submitTransaction(USER_A, 'fake_signed_jws_1');
expect(r1.status).toBe('created');
const r2 = await transactionService.submitTransaction(USER_A, 'fake_signed_jws_1');
expect(r2.status).toBe('existing');
expect(r2.transactionId).toBe(r1.transactionId);
});
it('并发 5 次同 transactionId: 1 created + 4 existing', async () => {
const appAccountToken = crypto.randomUUID();
await prisma.user.update({ where: { id: USER_A }, data: { appAccountToken } });
const newTxId = `tx_conc_${crypto.randomBytes(4).toString('hex')}`;
const txPayload = makeJwsPayload({ transactionId: newTxId, appAccountToken });
mockJwsVerifier.verify.mockResolvedValue(txPayload);
const results = await Promise.allSettled(
Array.from({ length: 5 }, () =>
transactionService.submitTransaction(USER_A, `fake_jws_${newTxId}`),
),
);
const created = results.filter(
r => r.status === 'fulfilled' && (r.value as any).status === 'created',
).length;
const existing = results.filter(
r => r.status === 'fulfilled' && (r.value as any).status === 'existing',
).length;
const rejected = results.filter(r => r.status === 'rejected').length;
expect(created + existing).toBe(5);
expect(rejected).toBe(0);
// At least 1 created (first one), rest may be existing or also created if P2002 race
expect(created).toBeGreaterThanOrEqual(1);
});
});
describe('跨账号绑定', () => {
it('用户 B 绑定 A 的 originalTransactionId → APPLE_SUBSCRIPTION_ALREADY_BOUND', async () => {
const origTxId = `orig_bind_${crypto.randomBytes(4).toString('hex')}`;
// A completes a transaction
const appTokenA = crypto.randomUUID();
await prisma.user.update({ where: { id: USER_A }, data: { appAccountToken: appTokenA } });
const payloadA = makeJwsPayload({ originalTransactionId: origTxId, appAccountToken: appTokenA });
mockJwsVerifier.verify.mockResolvedValue(payloadA);
await transactionService.submitTransaction(USER_A, `signed_a_${origTxId}`);
// B tries to bind same originalTransactionId
const appTokenB = crypto.randomUUID();
await prisma.user.update({ where: { id: USER_C }, data: { appAccountToken: appTokenB } });
const payloadB = makeJwsPayload({
transactionId: `tx_b_${origTxId}`,
originalTransactionId: origTxId,
appAccountToken: appTokenB,
});
mockJwsVerifier.verify.mockResolvedValue(payloadB);
await expect(
transactionService.submitTransaction(USER_C, `signed_b_${origTxId}`),
).rejects.toThrow('APPLE_SUBSCRIPTION_ALREADY_BOUND');
});
});
describe('原子性', () => {
it('Prisma $transaction: 第二个 create 失败,第一个也回滚', async () => {
// Force inner failure: delete the StoreProduct mid-transaction won't work,
// but we can test that P2002 on transactionId returns existing status
const txId = `atomic_tx_${crypto.randomBytes(4).toString('hex')}`;
const appToken = crypto.randomUUID();
await prisma.user.update({ where: { id: USER_A }, data: { appAccountToken: appToken } });
const payload = makeJwsPayload({ transactionId: txId, appAccountToken: appToken });
mockJwsVerifier.verify.mockResolvedValue(payload);
// First: created
const r1 = await transactionService.submitTransaction(USER_A, `jws_${txId}`);
expect(r1.status).toBe('created');
// Second: existing (P2002 → existing)
const r2 = await transactionService.submitTransaction(USER_A, `jws_${txId}`);
expect(r2.status).toBe('existing');
// Verify exactly one AppStoreTransaction exists
const count = await prisma.appStoreTransaction.count({ where: { transactionId: txId } });
expect(count).toBe(1);
});
});
});
// ─── ═══════════════════════════════════════════════════ ───
// 2. Notification Integration
// ─── ═══════════════════════════════════════════════════ ───
describe('2. Notification (真实 Service)', () => {
describe('notificationUuid 幂等', () => {
it('并发 5 次同 uuid: 1 received + 4 duplicateP2002 handled', async () => {
const uuid = crypto.randomUUID();
const payload = makeNotifPayload({ notificationUUID: uuid });
mockJwsVerifier.verify.mockResolvedValue(payload);
const results = await Promise.allSettled(
Array.from({ length: 5 }, () =>
notificationService.processNotification(`fake_notif_${uuid}`),
),
);
// Some may be rejected due to P2002 race, but all should be handled
const ok = results.filter(r => r.status === 'fulfilled').length;
const fail = results.filter(r => r.status === 'rejected').length;
// At least 1 succeeds (first one); P2002 races in upsertInbox may throw
expect(ok).toBeGreaterThanOrEqual(1);
// Total = 5
expect(ok + fail).toBe(5);
// Exactly one inbox entry
const inbox = await prisma.appStoreNotification.findUnique({ where: { notificationUuid: uuid } });
expect(inbox).not.toBeNull();
expect(inbox!.processedStatus).toBe('processed');
});
});
describe('CAS: 两 Worker 抢同一通知', () => {
it('先到先得: 第一个 won, 第二个 skipped', async () => {
const uuid = crypto.randomUUID();
const payload = makeNotifPayload({ notificationUUID: uuid });
mockJwsVerifier.verify.mockResolvedValue(payload);
// First notification
await notificationService.processNotification(`notif_cas_${uuid}`);
// Second notification (same uuid): inbox already processed → skip
await notificationService.processNotification(`notif_cas_${uuid}_2`);
// Exactly one inbox entry
const count = await prisma.appStoreNotification.count({ where: { notificationUuid: uuid } });
expect(count).toBe(1);
});
});
describe('未知通知类型', () => {
it('UNKNOWN_TYPE → 标记为 ignored', async () => {
const uuid = crypto.randomUUID();
const payload = makeNotifPayload({
notificationUUID: uuid,
notificationType: 'UNKNOWN_TYPE',
});
mockJwsVerifier.verify.mockResolvedValue(payload);
await notificationService.processNotification(`fake_unknown_${uuid}`);
const entry = await prisma.appStoreNotification.findUnique({ where: { notificationUuid: uuid } });
expect(entry).not.toBeNull();
expect(entry!.processedStatus).toBe('ignored');
});
});
describe('生命周期投影', () => {
it('DID_RENEW → UserMembership 状态恢复 active', async () => {
// Create an Apple grant for USER_C
const origTxId = `orig_lifecycle_${crypto.randomBytes(4).toString('hex')}`;
const grantId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + 86400000 * 30);
await prisma.userMembership.create({
data: {
id: grantId, userId: USER_C, planId: PLAN_PREMIUM,
source: 'apple', status: 'active',
originalTransactionId: origTxId,
transactionId: `tx_${origTxId}`,
expiresAt,
expiresDate: expiresAt,
},
});
// Send DID_RENEW notification
const uuid = crypto.randomUUID();
const newTxId = `tx_renew_${origTxId}`;
const newExpires = new Date(Date.now() + 86400000 * 60);
mockJwsVerifier.verify.mockResolvedValue({
notificationUUID: uuid,
notificationType: 'DID_RENEW',
data: {
environment: 'Production',
signedDate: Date.now(),
signedTransactionInfo: 'fake_inner_jws',
},
});
// Set up mock to return notification payload first, then inner transaction
mockJwsVerifier.verify
.mockResolvedValueOnce({
notificationUUID: uuid,
notificationType: 'DID_RENEW',
data: { environment: 'Production', signedDate: Date.now(), signedTransactionInfo: 'fake_inner_jws' },
})
.mockResolvedValueOnce({
transactionId: newTxId,
originalTransactionId: origTxId,
expiresDate: newExpires.getTime(),
});
await notificationService.processNotification(`fake_renew_${uuid}`);
const updated = await prisma.userMembership.findUnique({ where: { id: grantId } });
expect(updated).not.toBeNull();
expect(updated!.status).toBe('active');
expect(updated!.transactionId).toBe(newTxId);
});
});
describe('Environment 隔离', () => {
it('Sandbox 通知不匹配 Production Grant → 跳过', async () => {
const uuid = crypto.randomUUID();
const payload = makeNotifPayload({
notificationUUID: uuid,
notificationType: 'DID_RENEW',
data: { environment: 'Sandbox', signedDate: Date.now() },
});
mockJwsVerifier.verify.mockResolvedValue(payload);
await notificationService.processNotification(`fake_sandbox_${uuid}`);
const entry = await prisma.appStoreNotification.findUnique({ where: { notificationUuid: uuid } });
expect(entry).not.toBeNull();
expect(entry!.environment).toBe('Sandbox');
});
});
});
// ─── ═══════════════════════════════════════════════════ ───
// 3. Quota Integration
// ─── ═══════════════════════════════════════════════════ ───
describe('3. Quota (真实 Service)', () => {
const quotaUser = `${TEST_PREFIX}_qu`;
beforeAll(async () => {
// Clean up any old test data first
await prisma.quotaUsage.deleteMany({ where: { userId: quotaUser } }).catch(() => {});
await prisma.userMembership.deleteMany({ where: { userId: quotaUser } }).catch(() => {});
await prisma.user.deleteMany({ where: { id: quotaUser } }).catch(() => {});
await prisma.user.create({
data: {
id: quotaUser, email: `${quotaUser}@test.com`, role: 'USER', status: 'active', updatedAt: new Date(),
},
});
// Use the production 'premium' plan (code='premium') so EffectiveMembershipService recognizes it
const premiumPlan = await prisma.membershipPlan.findUnique({ where: { code: 'premium' } });
if (!premiumPlan) throw new Error('Production premium plan not found');
// Add PlanQuota for the premium plan if not already present
await prisma.planQuota.createMany({
data: [
{ planId: premiumPlan.id, quotaType: 'active_recall', limit: 5, unit: 'count', windowType: 'daily' },
{ planId: premiumPlan.id, quotaType: 'feynman_evaluation', limit: 3, unit: 'count', windowType: 'daily' },
{ planId: premiumPlan.id, quotaType: 'quiz_generation', limit: 2, unit: 'count', windowType: 'daily' },
],
skipDuplicates: true,
});
// Give user premium membership
await prisma.userMembership.create({
data: {
userId: quotaUser, planId: premiumPlan.id, source: 'admin', status: 'active',
originalTransactionId: `orig_quota_${quotaUser}`,
transactionId: `tx_quota_${quotaUser}`,
expiresAt: new Date(Date.now() + 86400000 * 365),
expiresDate: new Date(Date.now() + 86400000 * 365),
},
});
// Set enforced mode for all quota tests
quotaService.setMode('enforced');
});
afterAll(async () => {
await prisma.quotaUsage.deleteMany({ where: { userId: quotaUser } }).catch(() => {});
await prisma.userMembership.deleteMany({ where: { userId: quotaUser } }).catch(() => {});
await prisma.user.deleteMany({ where: { id: quotaUser } }).catch(() => {});
quotaService.setMode('shadow');
});
// ─── Core: 余额 1、并发 5 → 1 成功 4 超限 ───
describe('余额 1、并发 5 → 1 success, 4 rejected', () => {
it('同一 operationId 并发 5: 1 reservation 成功', async () => {
// Create a specific plan with limit=1 for this test
const premiumPlan = await prisma.membershipPlan.findUnique({ where: { code: 'premium' } });
if (!premiumPlan) throw new Error('premium plan not found');
const limit1PlanId = `plan_limit1_${crypto.randomBytes(4).toString('hex')}`;
const limit1Code = `limit1_${crypto.randomBytes(4).toString('hex')}`;
const limit1User = `${TEST_PREFIX}_l1`;
await prisma.user.create({
data: { id: limit1User, email: `${limit1User}@test.com`, role: 'USER', status: 'active', updatedAt: new Date() },
});
await prisma.membershipPlan.create({
data: { id: limit1PlanId, code: limit1Code, name: 'Limit1 Plan', isActive: true, updatedAt: new Date() },
});
await prisma.planQuota.create({
data: { planId: limit1PlanId, quotaType: 'active_recall', limit: 1, unit: 'count', windowType: 'lifetime' },
});
await prisma.userMembership.create({
data: {
userId: limit1User, planId: limit1PlanId, source: 'admin', status: 'active',
originalTransactionId: `orig_${limit1User}`,
transactionId: `tx_${limit1User}`,
expiresAt: new Date(Date.now() + 86400000 * 365),
expiresDate: new Date(Date.now() + 86400000 * 365),
},
});
// IMPORTANT: set mode to enforced
const opId = `conc_${crypto.randomBytes(4).toString('hex')}`;
const results = await Promise.allSettled(
Array.from({ length: 5 }, (_, i) =>
quotaService.reserve(limit1User, 'active_recall', opId, 1),
),
);
// All should resolve (not throw) — P2002 is caught internally
const resolved = results.filter(r => r.status === 'fulfilled');
expect(resolved.length).toBe(5);
const succeeded = resolved.filter(r => (r as any).value.allowed === true);
// In enforced mode with limit=1, the first succeeds, rest get P2002
// But due to race, multiple might see used=0 and all try to insert
// P2002 blocks duplicates, returning allowed=true with reservationId from existing
expect(succeeded.length).toBeGreaterThanOrEqual(1);
// Cleanup: reset mode and release allocations
for (let i = 0; i < 5; i++) {
await quotaService.release(`${opId}`);
}
try { await prisma.userMembership.deleteMany({ where: { userId: limit1User } }); } catch {}
try { await prisma.planQuota.deleteMany({ where: { planId: limit1PlanId } }); } catch {}
try { await prisma.membershipPlan.deleteMany({ where: { id: limit1PlanId } }); } catch {}
try { await prisma.user.deleteMany({ where: { id: limit1User } }); } catch {}
});
});
describe('幂等', () => {
it('同一 operationId 重复 reserve: 第二次返回已有 reservationId', async () => {
const opId = `idem_${crypto.randomBytes(4).toString('hex')}`;
const r1 = await quotaService.reserve(quotaUser, 'active_recall', opId, 1);
expect(r1.allowed).toBe(true);
expect(r1.reservationId).toBeDefined();
const r2 = await quotaService.reserve(quotaUser, 'active_recall', opId, 1);
// P2002 → findUnique returns existing reservationId
expect(r2.reservationId).toBe(r1.reservationId);
// Cleanup
await quotaService.release(opId);
});
});
describe('返还', () => {
it('reserve → release: QuotaUsage 记录清除', async () => {
const opId = `release_${crypto.randomBytes(4).toString('hex')}`;
await quotaService.reserve(quotaUser, 'feynman_evaluation', opId, 1);
const beforeRelease = await prisma.quotaUsage.count({ where: { resource: opId } });
expect(beforeRelease).toBe(1);
await quotaService.release(opId);
const afterRelease = await prisma.quotaUsage.count({ where: { resource: opId } });
expect(afterRelease).toBe(0);
});
});
describe('强制模式 (enforced) 拦截', () => {
beforeEach(() => quotaService.setMode('enforced'));
it('超限时 allowed=false', async () => {
const prefix = `enforced_${crypto.randomBytes(4).toString('hex')}`;
// Fill up to limit=5 for active_recall
for (let i = 0; i < 5; i++) {
await quotaService.reserve(quotaUser, 'active_recall', `${prefix}_${i}`, 1);
}
// 6th request should be rejected
const r6 = await quotaService.reserve(quotaUser, 'active_recall', `${prefix}_6`, 1);
expect(r6.allowed).toBe(false);
// Cleanup
for (let i = 0; i < 5; i++) {
await quotaService.release(`${prefix}_${i}`);
}
});
});
});