/** * Tests for `wrapWithLogging` (HOOK_API_V2 Phase 6 / D6). * * The wrapper is the framework's auto-logging layer for capability * methods. The contract: log `→ .` before, `✓` after, * `✗` on failure (then re-throw). Method names ONLY — never any * payload, never any result, because capability requests can carry * secrets. */ import { describe, expect, test } from 'bun:test'; import { wrapWithLogging } from '@celilo/capabilities'; import { createCapturingLogger } from '../hooks/logger'; describe('wrapWithLogging', () => { test('wraps async methods and emits arrow markers in order', async () => { const { logger, messages } = createCapturingLogger(); const wrapped = wrapWithLogging( { async greet(name: string): Promise { return `hello, ${name}`; }, }, logger, 'public_web', ); const result = await wrapped.greet('world'); expect(result).toBe('hello, world'); expect(messages).toEqual([ { level: 'info', message: '→ public_web.greet' }, { level: 'info', message: '✓ public_web.greet' }, ]); }); test('captures errors, logs them at error level, then re-throws', async () => { const { logger, messages } = createCapturingLogger(); const wrapped = wrapWithLogging( { async fail(): Promise { throw new Error('boom'); }, }, logger, 'idp', ); await expect(wrapped.fail()).rejects.toThrow('boom'); expect(messages).toEqual([ { level: 'info', message: '→ idp.fail' }, { level: 'error', message: '✗ idp.fail: boom' }, ]); }); test('does NOT log payload arguments — method name only', async () => { const { logger, messages } = createCapturingLogger(); const wrapped = wrapWithLogging( { async create_user(req: { username: string; password: string }): Promise { // Method body — payload should never appear in any log line. void req; }, }, logger, 'idp', ); await wrapped.create_user({ username: 'alice', password: 'super-secret-token' }); // Confirm the secret password never appears in any log message. for (const m of messages) { expect(m.message).not.toContain('alice'); expect(m.message).not.toContain('super-secret-token'); } // And the only log lines are the arrow markers. expect(messages.map((m) => m.message)).toEqual(['→ idp.create_user', '✓ idp.create_user']); }); test('does NOT log result values', async () => { const { logger, messages } = createCapturingLogger(); const wrapped = wrapWithLogging( { async create_oidc_client(): Promise<{ client_id: string; client_secret: string }> { return { client_id: 'abc', client_secret: 'this-is-a-secret-do-not-log' }; }, }, logger, 'idp', ); const result = await wrapped.create_oidc_client(); expect(result.client_secret).toBe('this-is-a-secret-do-not-log'); for (const m of messages) { expect(m.message).not.toContain('this-is-a-secret-do-not-log'); expect(m.message).not.toContain('client_id'); } }); test('preserves non-function properties unchanged', () => { const { logger } = createCapturingLogger(); const original = { version: '1.0.0', capability: 'idp' as const, async noop() {}, }; const wrapped = wrapWithLogging(original, logger, 'idp'); expect(wrapped.version).toBe('1.0.0'); expect(wrapped.capability).toBe('idp'); expect(typeof wrapped.noop).toBe('function'); }); test('does not mutate the original methods object', async () => { const { logger } = createCapturingLogger(); let originalCalls = 0; const original = { async tick() { originalCalls++; }, }; const wrapped = wrapWithLogging(original, logger, 'cap'); // Calling the original should NOT trip the wrapper logger. await original.tick(); expect(originalCalls).toBe(1); // The wrapped reference must be a different function. expect(wrapped.tick).not.toBe(original.tick); }); test('multiple methods on the same object are each wrapped independently', async () => { const { logger, messages } = createCapturingLogger(); const wrapped = wrapWithLogging( { async a(): Promise {}, async b(): Promise {}, async c(): Promise {}, }, logger, 'cap', ); await wrapped.a(); await wrapped.b(); await wrapped.c(); const messageTexts = messages.map((m) => m.message); expect(messageTexts).toEqual([ '→ cap.a', '✓ cap.a', '→ cap.b', '✓ cap.b', '→ cap.c', '✓ cap.c', ]); }); test('handles non-Error throws (string, plain object) by stringifying them', async () => { const { logger, messages } = createCapturingLogger(); const wrapped = wrapWithLogging( { async throwString(): Promise { throw 'literal string'; }, }, logger, 'cap', ); await expect(wrapped.throwString()).rejects.toBe('literal string'); expect(messages).toContainEqual({ level: 'error', message: '✗ cap.throwString: literal string', }); }); test('preserves `this` binding inside wrapped methods', async () => { // The wrapper always returns an async function regardless of whether // the original method was sync or async. This is fine in practice // because every real capability interface declares its methods as // async (`Promise` return types) — see PublicWebCapability, // IdpCapability, etc. Sync capability methods would be a type-system // lie that the existing interfaces don't allow. const { logger } = createCapturingLogger(); const original = { _state: 'initial', async setState(this: { _state: string }, next: string): Promise { this._state = next; }, async getState(this: { _state: string }): Promise { return this._state; }, }; const wrapped = wrapWithLogging(original, logger, 'cap'); await wrapped.setState('updated'); // The wrapped object shares state with the original via `this` // because both methods operate on the same wrapped instance. expect(await wrapped.getState()).toBe('updated'); }); });