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

189 lines
8.0 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
OpenWAClient,
OpenWAApiError,
OpenWAAuthError,
OpenWAForbiddenError,
OpenWANotFoundError,
OpenWAConflictError,
OpenWARateLimitError,
OpenWANotImplementedError,
OpenWATimeoutError,
} from '../src';
import type { FetchLike } from '../src';
import { MockTransport } from './helpers';
function client(transport: MockTransport): OpenWAClient {
return new OpenWAClient({
baseUrl: 'http://localhost:2785',
apiKey: 'owa_k1_test',
fetch: transport.asFetch(),
});
}
describe('OpenWAClient', () => {
it('requires baseUrl and apiKey', () => {
expect(() => new OpenWAClient({ baseUrl: '', apiKey: 'x' })).toThrow();
expect(() => new OpenWAClient({ baseUrl: 'http://x', apiKey: '' })).toThrow();
});
it('sends the API key as X-API-Key and JSON content type', async () => {
const t = new MockTransport().on('GET', '/api/sessions', { body: [] });
await client(t).sessions.list();
expect(t.lastCall!.headers['x-api-key']).toBe('owa_k1_test');
expect(t.lastCall!.headers['content-type']).toBe('application/json');
});
it('strips a trailing slash from baseUrl', async () => {
const t = new MockTransport().on('GET', '/api/sessions', { body: [] });
const c = new OpenWAClient({ baseUrl: 'http://localhost:2785/', apiKey: 'k', fetch: t.asFetch() });
await c.sessions.list();
expect(t.lastCall!.url).toBe('http://localhost:2785/api/sessions');
});
it('does not auto-follow redirects (passes redirect: manual to fetch)', async () => {
// Auto-following a redirect would re-send X-API-Key to the redirect target,
// potentially a different origin. The SDK must not follow silently.
let seenInit: RequestInit | undefined;
const recordingFetch: FetchLike = async (_url, init) => {
seenInit = init as RequestInit;
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
};
const c = new OpenWAClient({ baseUrl: 'http://x', apiKey: 'k', fetch: recordingFetch });
await c.health.check();
expect(seenInit?.redirect).toBe('manual');
});
it('treats an unfollowed redirect (3xx) as an error', async () => {
// Redirects are never followed, so a 3xx is not a usable response — it must throw, keeping the
// JS transport aligned with the Python and PHP SDKs (which now also error on >= 300).
const redirectingFetch: FetchLike = async () =>
new Response('{"redirected":true}', { status: 302, headers: { location: 'http://evil.example/x' } });
const c = new OpenWAClient({ baseUrl: 'http://x', apiKey: 'k', fetch: redirectingFetch });
await expect(c.sessions.list()).rejects.toThrow();
});
it('surfaces a real opaque unfollowed redirect (status 0) as a clear OpenWAApiError', async () => {
// With `redirect: 'manual'` the runtime returns an opaque response with status 0 (not a 3xx);
// this is the actual shape the no-redirect guard produces, and it must throw a clear error.
const opaqueRedirectFetch: FetchLike = async () => Response.error();
const c = new OpenWAClient({ baseUrl: 'http://x', apiKey: 'k', fetch: opaqueRedirectFetch });
const err = await c.sessions.list().catch((e: unknown) => e);
expect(err).toBeInstanceOf(OpenWAApiError);
expect((err as OpenWAApiError).status).toBe(0);
expect((err as OpenWAApiError).message).toMatch(/redirect/i);
});
it('percent-encodes path segments but keeps @ in JIDs readable', async () => {
const t = new MockTransport().on('GET', /\/history$/, { body: [] });
await client(t).messages.history('s', 'a@c.us');
expect(t.lastCall!.url).toContain('/messages/a@c.us/history'); // @ preserved
const t2 = new MockTransport().on('GET', /\/history$/, { body: [] });
await client(t2).messages.history('s', 'weird/id#x');
expect(t2.lastCall!.url).toContain('weird%2Fid%23x'); // path-breaking chars encoded
});
it('serializes query params and skips null/undefined', async () => {
const t = new MockTransport().on('GET', /\/messages/, { body: [] });
await client(t).messages.list('s1', { chatId: 'a@c.us', from: undefined, limit: 10 });
expect(t.lastCall!.url).toContain('chatId=a%40c.us');
expect(t.lastCall!.url).toContain('limit=10');
expect(t.lastCall!.url).not.toContain('from=');
});
it('maps a 404 to OpenWANotFoundError with parsed body', async () => {
const t = new MockTransport().on('GET', '/api/sessions/missing', {
status: 404,
body: { statusCode: 404, message: 'Session not found', error: 'Not Found' },
});
await expect(client(t).sessions.get('missing')).rejects.toBeInstanceOf(OpenWANotFoundError);
await expect(client(t).sessions.get('missing')).rejects.toMatchObject({ status: 404 });
});
it('exposes all expected resource properties', () => {
const c = client(new MockTransport());
for (const r of ['sessions', 'messages', 'contacts', 'groups', 'webhooks', 'chats', 'status', 'health']) {
expect(c).toHaveProperty(r);
}
});
it('treats 204 as a null result', async () => {
const t = new MockTransport().on('DELETE', '/api/sessions/x', { status: 204 });
await expect(client(t).sessions.delete('x')).resolves.toBeNull();
});
it('OpenWAApiError.fromResponse parses the NestJS envelope', async () => {
const t = new MockTransport().on('POST', /send-text/, {
status: 409,
body: { statusCode: 409, message: 'Engine not ready', error: 'Conflict' },
});
await expect(client(t).messages.sendText('s', { chatId: 'a@c.us', text: 'hi' })).rejects.toBeInstanceOf(
OpenWAApiError,
);
});
it('maps each status code to its typed error subclass', async () => {
const cases: Array<[number, new (...a: never[]) => OpenWAApiError]> = [
[401, OpenWAAuthError],
[403, OpenWAForbiddenError],
[404, OpenWANotFoundError],
[409, OpenWAConflictError],
[429, OpenWARateLimitError],
[501, OpenWANotImplementedError],
];
for (const [status, cls] of cases) {
const t = new MockTransport().on('GET', '/api/sessions', {
status,
body: { statusCode: status, message: 'x', error: 'E' },
});
await expect(client(t).sessions.list()).rejects.toBeInstanceOf(cls);
}
});
it('falls back to the generic OpenWAApiError (with .status) for an unmapped status', async () => {
const t = new MockTransport().on('GET', '/api/sessions', {
status: 418,
body: { statusCode: 418, message: 'teapot', error: 'Teapot' },
});
await expect(client(t).sessions.list()).rejects.toMatchObject({ status: 418 });
await expect(client(t).sessions.list()).rejects.toBeInstanceOf(OpenWAApiError);
});
it('throws OpenWATimeoutError when the request aborts', async () => {
const abortingFetch: FetchLike = async () => {
const e = new Error('aborted');
e.name = 'AbortError';
throw e;
};
const c = new OpenWAClient({ baseUrl: 'http://x', apiKey: 'k', fetch: abortingFetch });
await expect(c.sessions.list()).rejects.toBeInstanceOf(OpenWATimeoutError);
});
it('keeps X-API-Key winning over defaultHeaders', async () => {
const t = new MockTransport().on('GET', '/api/sessions', { body: [] });
const c = new OpenWAClient({
baseUrl: 'http://x',
apiKey: 'REAL',
defaultHeaders: { 'X-API-Key': 'EVIL', 'X-Trace': 'keep' },
fetch: t.asFetch(),
});
await c.sessions.list();
expect(t.lastCall!.headers['x-api-key']).toBe('REAL');
expect(t.lastCall!.headers['x-trace']).toBe('keep');
});
it('keeps the JSON Content-Type winning over a defaultHeaders override', async () => {
const t = new MockTransport().on('GET', '/api/sessions', { body: [] });
const c = new OpenWAClient({
baseUrl: 'http://x',
apiKey: 'k',
defaultHeaders: { 'Content-Type': 'text/plain', 'X-Trace': 'keep' },
fetch: t.asFetch(),
});
await c.sessions.list();
// JSON wins (matches the Python/PHP SDKs), but an unrelated default header is still preserved.
expect(t.lastCall!.headers['content-type']).toBe('application/json');
expect(t.lastCall!.headers['x-trace']).toBe('keep');
});
});