Files
wehub-resource-sync 4ce4204b6c
CI / Lint (push) Failing after 2s
CI / Build (push) Has been skipped
SDK CI / PHP SDK (push) Failing after 1s
Split PHP SDK / PHP SDK tests (push) Failing after 1s
CI / Test (push) Failing after 1s
CI / Dashboard (push) Failing after 0s
SDK CI / JavaScript SDK (push) Failing after 0s
SDK CI / Python SDK (push) Failing after 2s
SDK CI / Java SDK (push) Failing after 1s
Split PHP SDK / Mirror sdk/php -> rmyndharis/openwa-php (push) Has been skipped
CI / Test (PostgreSQL migrations) (push) Failing after 7m47s
CI / Docker Build (push) Has been skipped
chore: import upstream snapshot with attribution
2026-07-13 12:24:08 +08:00

190 lines
9.2 KiB
TypeScript

// archiver v8 is ESM-only and is pulled in transitively via the @Global StorageModule when
// AppModule boots; stub it so ts-jest (CommonJS) can load the module graph (same as other e2e specs).
jest.mock('archiver', () => ({ TarArchive: jest.fn() }));
// Seed the well-known dev-admin-key (ApiKeyRole.ADMIN, so it satisfies @RequireRole(OPERATOR))
// BEFORE AppModule is imported, mirroring integration-instance.e2e-spec.ts's env-first pattern.
process.env.ALLOW_DEV_API_KEY = 'true';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
import { Message, MessageDirection } from '../src/modules/message/entities/message.entity';
import { AddMessagesFts1782400000000 } from '../src/database/migrations/1782400000000-AddMessagesFts';
import { SearchController } from '../src/modules/search/search.controller';
import { SearchService } from '../src/modules/search/search.service';
import { SearchProviderRegistry } from '../src/modules/search/search-provider.registry';
/**
* End-to-end coverage for the global search integration capstone (Task 10): the real /api/search
* route → SearchController → SearchService → SearchProviderRegistry → BuiltInFtsProvider →
* `messages` FTS schema (SQLite FTS5 here, Postgres tsvector in the provider's own spec). Bootstraps
* the full AppModule so the SEARCH_BOOTSTRAP factory, the `@InjectDataSource('data')` wiring, and the
* conditional `SEARCH_ENABLED` gate are all exercised exactly as production runs them.
*
* setup-e2e.ts sets DATABASE_SYNCHRONIZE=true, so TypeORM creates the `messages` table from the entity
* but the FTS virtual table + triggers come from a MIGRATION that doesn't run under synchronize. The
* spec applies that migration's up() manually after boot — proving the provider queries the FTS schema
* the migration establishes, not a test-only stub.
*/
describe('GET /api/search (e2e)', () => {
let app: INestApplication<App>;
let messageRepo: Repository<Message>;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api');
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
// The FTS migration is gated behind migrationsRun (off under DATABASE_SYNCHRONIZE=true), so apply
// it manually here against the 'data' connection that owns `messages`. This is the same shape the
// provider's own spec uses, and proves the e2e path queries a real FTS index, not a stand-in.
const dataSource = app.get<DataSource>(getDataSourceToken('data'));
await new AddMessagesFts1782400000000().up(dataSource.createQueryRunner());
messageRepo = app.get<Repository<Message>>(getRepositoryToken(Message, 'data'));
await messageRepo.insert([
{
sessionId: 'sess-search-1',
chatId: 'chat-1@c.us',
from: 'alice@c.us',
to: 'bot@c.us',
body: 'hello world from search e2e',
type: 'text',
direction: MessageDirection.OUTGOING,
timestamp: 1700000000000,
},
{
sessionId: 'sess-search-1',
chatId: 'chat-1@c.us',
from: 'bob@c.us',
to: 'bot@c.us',
body: 'completely unrelated body text',
type: 'text',
direction: MessageDirection.INCOMING,
timestamp: 1700000001000,
},
]);
});
afterAll(async () => {
try {
await app?.close();
} catch {
/* ignore teardown-only multi-datasource quirk */
}
});
it('returns matching hits over the full route → provider → FTS stack (200)', async () => {
const res = await request(app.getHttpServer())
.get('/api/search?q=hello')
.set('X-API-Key', 'dev-admin-key')
.expect(200);
const body = res.body as {
hits: Array<{ body: string; sessionId: string; snippet: string }>;
total: number;
provider: string;
};
expect(body.provider).toBe('builtin-fts');
expect(body.total).toBe(1);
expect(body.hits).toHaveLength(1);
expect(body.hits[0].body).toContain('hello world');
expect(body.hits[0].sessionId).toBe('sess-search-1');
// The SQLite snippet() wrapper highlights the match term with <mark> — proves the FTS index answered.
expect(body.hits[0].snippet).toContain('<mark>hello</mark>');
});
it('returns 400 for an empty / whitespace-only q', async () => {
// The DTO's @IsNotEmpty() surfaces as a 400 via the global ValidationPipe before the controller's
// own non-empty check runs; both paths converge on 400, which is the contract.
await request(app.getHttpServer()).get('/api/search?q=').set('X-API-Key', 'dev-admin-key').expect(400);
await request(app.getHttpServer()).get('/api/search?q=%20%20').set('X-API-Key', 'dev-admin-key').expect(400);
});
it('requires an API key (401 without one)', async () => {
await request(app.getHttpServer()).get('/api/search?q=hello').expect(401);
});
it('returns an empty result page (not 501) for a non-matching term', async () => {
const res = await request(app.getHttpServer())
.get('/api/search?q=zzzznomatch')
.set('X-API-Key', 'dev-admin-key')
.expect(200);
const body = res.body as { hits: unknown[]; total: number };
expect(body.hits).toEqual([]);
expect(body.total).toBe(0);
});
// Exercises the SEARCH_BOOTSTRAP factory + the `@InjectDataSource('data')` wiring: the controller,
// service, registry, and an ACTIVE provider are all resolvable from the booted DI graph, and the
// registry reports builtin-fts as active (so the route is live, not 501). This is the registration-
// level proof that SearchModule's providers and bootstrap factory wired correctly.
it('registers SearchModule with the built-in provider active (not 501)', () => {
expect(() => app.get(SearchController)).not.toThrow();
expect(() => app.get(SearchService)).not.toThrow();
const registry = app.get(SearchProviderRegistry);
expect(registry.active()?.id).toBe('builtin-fts');
});
});
/**
* The SEARCH_ENABLED=false gate (src/app.module.ts): the top-level conditional only pushes
* SearchModule into `searchModules` (spread into AppModule's @Module imports) when the env is not
* 'false', so an opt-out deployment never imports SearchModule at all — no route, controller,
* service, registry, or provider (zero footprint, no 501 surface).
*
* Rather than re-implementing the gate branch locally (which would only assert a copy of the logic
* and miss a regression in the real one), this re-evaluates the REAL app.module.ts under each env
* (jest.isolateModules gives a fresh registry so the top-level conditional re-runs with the env we
* set) and reads the ACTUAL @Module({ imports }) metadata, asserting whether SearchModule is present.
* A full route-404 e2e would need a separately-booted app in a different process/env, which is why the
* main describe above boots only the default (ON) instance; this pins the real opt-out decision.
*/
describe('SEARCH_ENABLED gate (real AppModule imports)', () => {
// Re-requires the REAL app.module.ts under a fresh module registry with SEARCH_ENABLED=`value`,
// then reports whether its actual @Module({ imports }) contains SearchModule. require() (not a
// dynamic import()) avoids the TS2835 extension requirement under moduleResolution:nodenext.
const searchModuleImportedWhen = (value: string | undefined): boolean => {
const previous = process.env.SEARCH_ENABLED;
if (value === undefined) delete process.env.SEARCH_ENABLED;
else process.env.SEARCH_ENABLED = value;
try {
let imported = false;
jest.isolateModules(() => {
/* eslint-disable @typescript-eslint/no-require-imports -- require() (not a dynamic import())
is the jest pattern for a fresh-evaluation read; the nodenext TS2835 extension rule only
applies to ES imports, and a dynamic import() would need a separate tsconfig for specs. */
const { AppModule } = require('../src/app.module') as typeof import('../src/app.module');
const { SearchModule } =
require('../src/modules/search/search.module') as typeof import('../src/modules/search/search.module');
/* eslint-enable @typescript-eslint/no-require-imports */
const imports: unknown[] = (Reflect.getMetadata('imports', AppModule) as unknown[]) ?? [];
imported = imports.includes(SearchModule);
});
return imported;
} finally {
if (previous === undefined) delete process.env.SEARCH_ENABLED;
else process.env.SEARCH_ENABLED = previous;
}
};
it('imports SearchModule by default (SEARCH_ENABLED unset)', () => {
expect(searchModuleImportedWhen(undefined)).toBe(true);
});
it('imports SearchModule when explicitly enabled', () => {
expect(searchModuleImportedWhen('true')).toBe(true);
});
it('omits SearchModule entirely when SEARCH_ENABLED=false (route cannot register)', () => {
expect(searchModuleImportedWhen('false')).toBe(false);
});
});