Builds the deferred integration tests from docs/backlog/feature-test-coverage- deferred.md that needed real infra (a test Postgres + real Redis) which the repo lacked. Runs against an isolated, auto-created docmost_test database and Redis logical DB 15 — never the dev data. Harness (apps/server/test/integration/, run via new `pnpm --filter server test:int` => jest --config test/jest-integration.json; default unit `jest` is untouched and excludes these via the *.int-spec.ts name + rootDir): - db.ts: buildTestDb() mirrors database.module.ts exactly (PostgresJSDialect, CamelCasePlugin, bigint to:20/from:[20,1700] parsing) + minimal seed helpers. - global-setup.ts: DROP/CREATE docmost_test, CREATE EXTENSION vector, migrate to latest via Kysely Migrator (fails loud on any errored migration). - global-teardown.ts: closes the pool. Coverage (5 suites, 16 tests, all green against live PG+Redis): - WorkspaceRepo.updateSetting: jsonb-merge persists htmlEmbed without clobbering sibling ai/sharing namespaces (the kill-switch write half). - AiAgentRoleRepo: soft-delete exclusion, cross-workspace tenant isolation, duplicate (name,workspace) -> 23505, name reusable after softDelete (partial unique index WHERE deleted_at IS NULL), same name across workspaces allowed. - page_template_references: deleting either source or referenced page cascades the link row (onDelete cascade) — real FK, not mocked. - PublicShareWorkspaceLimiter vs REAL Redis: real ioredis EVAL of the sliding- window Lua — max boundary (3 admit / 4th deny), re-admit after the window slides, same-ms distinct members. Catches Lua bugs a FakeRedis cannot. - AiChatRepo.findByCreator: role-badge join (enabled->badge; soft-deleted or disabled role -> null). Review: APPROVE; applied its two hardening suggestions (fail loud on errored migration result even without a top-level error; TEST_REDIS_URL override + ping preflight). tsc clean; unit run excludes int-spec (verified). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
76 lines
2.9 KiB
TypeScript
76 lines
2.9 KiB
TypeScript
import Redis from 'ioredis';
|
|
import { PublicShareWorkspaceLimiter } from 'src/core/ai-chat/public-share-workspace-limiter';
|
|
|
|
/**
|
|
* D — PublicShareWorkspaceLimiter against REAL Redis (logical DB 15, so nothing
|
|
* touches dev data). This exercises the actual Lua EVAL — including
|
|
* ZREMRANGEBYSCORE eviction and the `ZCARD >= max` boundary — which a FakeRedis
|
|
* cannot faithfully reproduce.
|
|
*/
|
|
describe('PublicShareWorkspaceLimiter vs real Redis [integration]', () => {
|
|
let redis: Redis;
|
|
|
|
beforeAll(async () => {
|
|
// db:15 keeps this off the app's db 0, so dev Redis data is never touched.
|
|
const url = process.env.TEST_REDIS_URL ?? 'redis://127.0.0.1:6379';
|
|
redis = new Redis(url, { db: 15, lazyConnect: false });
|
|
// Surface an unreachable/wrong Redis here with a clear error, not mid-test.
|
|
await redis.ping();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await redis.flushdb();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await redis.quit();
|
|
});
|
|
|
|
it('admits the first max calls and denies the next, then re-admits after the window slides', async () => {
|
|
let nowMs = 1_000_000;
|
|
const now = () => nowMs;
|
|
const limiter = new PublicShareWorkspaceLimiter(redis, 3, 1000, now);
|
|
const key = 'ws-sliding';
|
|
|
|
// First 3 admitted.
|
|
expect(await limiter.tryConsume(key)).toBe(true);
|
|
expect(await limiter.tryConsume(key)).toBe(true);
|
|
expect(await limiter.tryConsume(key)).toBe(true);
|
|
// 4th denied (cap reached; ZCARD >= max).
|
|
expect(await limiter.tryConsume(key)).toBe(false);
|
|
|
|
// Advance time past the window so all 3 entries fall out of the trailing
|
|
// windowMs and ZREMRANGEBYSCORE evicts them.
|
|
nowMs += 1500;
|
|
expect(await limiter.tryConsume(key)).toBe(true);
|
|
});
|
|
|
|
it('counts 3 distinct same-millisecond calls distinctly, then denies the 4th', async () => {
|
|
// Fixed `now` => all attempts share the same timestamp. Unique member ids
|
|
// (counter + random suffix) keep them distinct in the sorted set so the
|
|
// count is not under-reported by score collision.
|
|
const now = () => 2_000_000;
|
|
const limiter = new PublicShareWorkspaceLimiter(redis, 3, 1000, now);
|
|
const key = 'ws-same-ms';
|
|
|
|
expect(await limiter.tryConsume(key)).toBe(true);
|
|
expect(await limiter.tryConsume(key)).toBe(true);
|
|
expect(await limiter.tryConsume(key)).toBe(true);
|
|
expect(await limiter.tryConsume(key)).toBe(false);
|
|
|
|
// Confirm the sorted set actually holds 3 distinct members at one score.
|
|
const card = await redis.zcard('share-ai:ws:' + key);
|
|
expect(card).toBe(3);
|
|
});
|
|
|
|
it('keys are isolated per workspace', async () => {
|
|
const now = () => 3_000_000;
|
|
const limiter = new PublicShareWorkspaceLimiter(redis, 1, 1000, now);
|
|
|
|
expect(await limiter.tryConsume('ws-a')).toBe(true);
|
|
expect(await limiter.tryConsume('ws-a')).toBe(false);
|
|
// Different key has its own independent budget.
|
|
expect(await limiter.tryConsume('ws-b')).toBe(true);
|
|
});
|
|
});
|