import {describe, expect, test} from 'bun:test' import {AwsLogsAdapter} from './AwsLogsAdapter' /** * Stubs the SDK client's `send`, dispatching on the command's constructor name. * Matching the real SDK's *name* rather than the wire shape is deliberate — see * .claude/memory/aws-sdk-error-names-differ-from-wire-codes.md. */ function stubClient(handlers: Record) => unknown>) { const calls: Array<{command: string; input: Record}> = [] const client = { async send(command: {constructor: {name: string}; input: Record}) { const name = command.constructor.name calls.push({command: name, input: command.input}) const handler = handlers[name] if (!handler) throw new Error(`unexpected command ${name}`) return handler(command.input) }, } return {client: client as never, calls} } describe('AwsLogsAdapter resources', () => { test('normalizes log groups as cloud resources', async () => { const {client} = stubClient({ DescribeLogGroupsCommand: () => ({ logGroups: [{ logGroupName: '/floci/probe', creationTime: 1785260691820, arn: 'arn:aws:logs:us-east-1:000000000000:log-group:/floci/probe', storedBytes: 0, metricFilterCount: 0, }], }), }) const adapter = new AwsLogsAdapter(client) await expect(adapter.list()).resolves.toMatchObject([{ id: '/floci/probe', name: '/floci/probe', cloud: 'aws', service: 'logs', type: 'log-group', createdAt: new Date(1785260691820).toISOString(), }]) }) // The runtime filters natively by prefix, so `search` must not be a // client-side contains — that would silently disagree with the field label. test('search is sent as logGroupNamePrefix', async () => { const {client, calls} = stubClient({DescribeLogGroupsCommand: () => ({logGroups: []})}) const adapter = new AwsLogsAdapter(client) await adapter.list({search: '/floci'}) expect(calls[0].input).toMatchObject({logGroupNamePrefix: '/floci'}) }) test('get returns null for a group the runtime does not have', async () => { const {client} = stubClient({DescribeLogGroupsCommand: () => ({logGroups: []})}) const adapter = new AwsLogsAdapter(client) await expect(adapter.get('/nope')).resolves.toBeNull() }) // DescribeLogGroups is a prefix match, so a longer name starting with the // requested one would otherwise be returned as if it were an exact hit. test('get matches the name exactly, not by prefix', async () => { const {client} = stubClient({ DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/floci/probe-two', creationTime: 1}]}), }) const adapter = new AwsLogsAdapter(client) await expect(adapter.get('/floci/probe')).resolves.toBeNull() }) test('create and delete issue the right commands', async () => { const {client, calls} = stubClient({ CreateLogGroupCommand: () => ({}), DeleteLogGroupCommand: () => ({}), DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/floci/new', creationTime: 1}]}), }) const adapter = new AwsLogsAdapter(client) await expect(adapter.create({values: {name: '/floci/new'}})).resolves.toMatchObject({id: '/floci/new'}) await adapter.delete('/floci/new') expect(calls.map((call) => call.command)).toEqual([ 'CreateLogGroupCommand', 'DescribeLogGroupsCommand', 'DeleteLogGroupCommand', ]) }) /** * `creationTime` is the field the SDK models and real AWS returns. The * local runtime sends `createdTime`, which the SDK deserializer discards — * so createdAt is legitimately null against the emulator, and a stub that * feeds `createdTime` is testing a shape the SDK can never produce. That * mistake shipped here once already; this pins the real contract. */ test('reads the creation timestamp the SDK actually models', async () => { const {client} = stubClient({ DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/a', creationTime: 1785260691820}]}), }) await expect(new AwsLogsAdapter(client).list()).resolves.toMatchObject([ {createdAt: new Date(1785260691820).toISOString()}, ]) }) test('a group with no modelled timestamp reports null rather than a bogus date', async () => { const {client} = stubClient({ DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/a'}]}), }) await expect(new AwsLogsAdapter(client).list()).resolves.toMatchObject([{createdAt: null}]) }) test('create rejects a blank name before calling the runtime', async () => { const {client, calls} = stubClient({}) const adapter = new AwsLogsAdapter(client) await expect(adapter.create({values: {name: ' '}})).rejects.toThrow() expect(calls).toEqual([]) }) }) describe('AwsLogsAdapter queryLogs', () => { test('starts a query and maps result fields into row objects', async () => { const {client, calls} = stubClient({ StartQueryCommand: () => ({queryId: 'q-1'}), GetQueryResultsCommand: () => ({ status: 'Complete', results: [ [{field: '@timestamp', value: '2026-01-01 00:00:00.000'}, {field: '@message', value: 'hello'}], ], }), }) const adapter = new AwsLogsAdapter(client) const result = await adapter.queryLogs('/floci/probe', { queryString: 'fields @timestamp, @message', startTime: 1000, endTime: 2000, limit: 20, }) expect(result).toEqual({ queryId: 'q-1', status: 'Complete', rows: [{'@timestamp': '2026-01-01 00:00:00.000', '@message': 'hello'}], }) expect(calls[0]).toMatchObject({ command: 'StartQueryCommand', input: {logGroupName: '/floci/probe', startTime: 1000, endTime: 2000, queryString: 'fields @timestamp, @message', limit: 20}, }) expect(calls[1].command).toBe('GetQueryResultsCommand') }) // FLOCI_SERVICES_CLOUDWATCHLOGS_QUERY_COMPLETION_DELAY_MS defaults to 0, so a // real completion normally takes one GetQueryResults call — but a positive // delay (or real AWS) makes the first call(s) come back Running, so the // adapter must keep polling rather than returning a stale in-progress result. test('polls until the query leaves Running/Scheduled', async () => { let getCalls = 0 const {client, calls} = stubClient({ StartQueryCommand: () => ({queryId: 'q-2'}), GetQueryResultsCommand: () => { getCalls += 1 if (getCalls < 3) return {status: getCalls === 1 ? 'Scheduled' : 'Running', results: []} return {status: 'Complete', results: [[{field: '@message', value: 'done'}]]} }, }) const adapter = new AwsLogsAdapter(client) const result = await adapter.queryLogs('/floci/probe', {queryString: 'fields @message', startTime: 0, endTime: 1}) expect(result).toEqual({queryId: 'q-2', status: 'Complete', rows: [{'@message': 'done'}]}) expect(calls.filter((call) => call.command === 'GetQueryResultsCommand')).toHaveLength(3) }) // The P1 this test pins: if the poll deadline is hit while the query is // still Running/Scheduled, the caller must still get the queryId back — // dropping it here would make that still-running query unrecoverable, even // though Floci (or real AWS) keeps executing it server-side. test('still returns the query id when the poll deadline is hit before completion', async () => { const {client} = stubClient({ StartQueryCommand: () => ({queryId: 'q-3'}), GetQueryResultsCommand: () => ({status: 'Running', results: []}), }) const adapter = new AwsLogsAdapter(client, {timeoutMs: 30, intervalMs: 10}) const result = await adapter.queryLogs('/floci/probe', {queryString: 'fields @message', startTime: 0, endTime: 1}) expect(result.queryId).toBe('q-3') expect(result.status).toBe('Running') }) test('rejects a blank query string before calling the runtime', async () => { const {client, calls} = stubClient({}) const adapter = new AwsLogsAdapter(client) await expect(adapter.queryLogs('/floci/probe', {queryString: ' ', startTime: 0, endTime: 1})) .rejects.toThrow() expect(calls).toEqual([]) }) test('raises when the runtime does not hand back a query id', async () => { const {client} = stubClient({StartQueryCommand: () => ({})}) const adapter = new AwsLogsAdapter(client) await expect(adapter.queryLogs('/floci/probe', {queryString: 'fields @message', startTime: 0, endTime: 1})) .rejects.toThrow() }) }) describe('AwsLogsAdapter filterLogEvents', () => { test('filters across streams and preserves event identity and pagination', async () => { const {client, calls} = stubClient({ FilterLogEventsCommand: () => ({ events: [{eventId: 'event-1', logStreamName: 'stream-a', timestamp: 1785260000000, message: 'ERROR failed', ingestionTime: 1785260001000}], nextToken: 'page-2', }), }) const page = await new AwsLogsAdapter(client).filterLogEvents('/floci/probe', { filterPattern: 'ERROR', logStreamNames: ['stream-a'], startTime: 1000, endTime: 2000, }, {cursor: 'page-1', limit: 25}) expect(calls[0]).toMatchObject({command: 'FilterLogEventsCommand', input: { logGroupName: '/floci/probe', filterPattern: 'ERROR', logStreamNames: ['stream-a'], startTime: 1000, endTime: 2000, nextToken: 'page-1', limit: 25, }}) expect(page).toEqual({ items: [{ id: 'event-1', collectionId: 'stream-a', timestamp: new Date(1785260000000).toISOString(), body: {message: 'ERROR failed'}, metadata: {ingestionTime: new Date(1785260001000).toISOString()}, }], nextCursor: 'page-2', }) }) test('retains a continuation token on an empty page', async () => { const {client} = stubClient({FilterLogEventsCommand: () => ({events: [], nextToken: 'keep-going'})}) await expect(new AwsLogsAdapter(client).filterLogEvents('/floci/probe', {})) .resolves.toEqual({items: [], nextCursor: 'keep-going'}) }) }) describe('AwsLogsAdapter documents', () => { test('lists log streams as collections, newest activity first', async () => { const {client, calls} = stubClient({ DescribeLogStreamsCommand: () => ({ logStreams: [{ logStreamName: 'stream-a', arn: 'arn:aws:logs:us-east-1:000000000000:log-group:/floci/probe:log-stream:stream-a', creationTime: 1785260702176, storedBytes: 70, firstEventTimestamp: 1785260000000, lastEventTimestamp: 1785260001000, }], nextToken: 'page-2', }), }) const adapter = new AwsLogsAdapter(client) const page = await adapter.documents.listCollections('/floci/probe') expect(page).toMatchObject({ items: [{id: 'stream-a', name: 'stream-a', parentId: '/floci/probe'}], nextCursor: 'page-2', }) expect(calls[0].input).toMatchObject({ logGroupName: '/floci/probe', orderBy: 'LastEventTime', descending: true, }) }) /** * Real AWS caps DescribeLogStreams Limit at 50 and rejects anything higher * with a ValidationException. The SPI defaults to 100 and permits 1000, and * the local runtime does not enforce the cap — so an unclamped value passes * every local test and 400s in production. Encode the provider's rule. */ test('clamps the stream page size to the AWS maximum of 50', async () => { const {client, calls} = stubClient({DescribeLogStreamsCommand: () => ({logStreams: []})}) const adapter = new AwsLogsAdapter(client) await adapter.documents.listCollections('/floci/probe') await adapter.documents.listCollections('/floci/probe', {limit: 1000}) await adapter.documents.listCollections('/floci/probe', {limit: 25}) expect(calls.map((call) => call.input.limit)).toEqual([50, 50, 25]) }) test('reads log events as items, oldest first', async () => { const {client, calls} = stubClient({ GetLogEventsCommand: () => ({ events: [ {timestamp: 1785260000000, message: 'hello one', ingestionTime: 1785260702192}, {timestamp: 1785260001000, message: 'hello two', ingestionTime: 1785260702192}, ], nextForwardToken: 'f/2', nextBackwardToken: 'b/0', }), }) const adapter = new AwsLogsAdapter(client) const page = await adapter.documents.listItems('/floci/probe', 'stream-a') // Real AWS gives events no id, so it is derived from timestamp and // position. Two events sharing a millisecond still get distinct ids. expect(page.items).toMatchObject([ { id: '1785260000000-0', collectionId: 'stream-a', timestamp: new Date(1785260000000).toISOString(), body: {message: 'hello one'}, }, {id: '1785260001000-1', collectionId: 'stream-a'}, ]) expect(page.nextCursor).toBe('f/2') // Without startFromHead the runtime returns the newest page, so paging // forward from a fresh cursor would skip the history. expect(calls[0].input).toMatchObject({startFromHead: true}) }) /** * The termination rule. GetLogEvents echoes the supplied token back when * there is nothing further, so a cursor loop that trusts a non-null token * never ends. Verified against the runtime on 2026-07-28. */ test('stops paging when the runtime echoes the cursor back', async () => { const {client} = stubClient({ GetLogEventsCommand: () => ({events: [], nextForwardToken: 'f/2', nextBackwardToken: 'b/2'}), }) const adapter = new AwsLogsAdapter(client) const page = await adapter.documents.listItems('/floci/probe', 'stream-a', {cursor: 'f/2'}) expect(page.items).toEqual([]) expect(page.nextCursor).toBeNull() }) test('an empty first page terminates too', async () => { const {client} = stubClient({ GetLogEventsCommand: () => ({events: [], nextForwardToken: 'f/0', nextBackwardToken: 'b/0'}), }) const adapter = new AwsLogsAdapter(client) await expect(adapter.documents.listItems('/floci/probe', 'stream-a')).resolves.toMatchObject({nextCursor: null}) }) test('creates and deletes a log stream', async () => { const {client, calls} = stubClient({ CreateLogStreamCommand: () => ({}), DeleteLogStreamCommand: () => ({}), DescribeLogStreamsCommand: () => ({ logStreams: [{logStreamName: 'stream-b', creationTime: 1785260702176}], }), }) const adapter = new AwsLogsAdapter(client) await expect(adapter.documents.createCollection!('/floci/probe', {values: {name: 'stream-b'}})) .resolves.toMatchObject({id: 'stream-b', parentId: '/floci/probe'}) await adapter.documents.deleteCollection!('/floci/probe', 'stream-b') expect(calls.map((call) => call.command)).toEqual([ 'CreateLogStreamCommand', 'DescribeLogStreamsCommand', 'DeleteLogStreamCommand', ]) }) // Provider behaviour, verified: a missing group raises, a missing stream // returns an empty page. The contract mirrors that rather than smoothing it. test('a missing stream yields an empty page rather than an error', async () => { const {client} = stubClient({ GetLogEventsCommand: () => ({events: [], nextForwardToken: 'f/0', nextBackwardToken: 'b/0'}), }) const adapter = new AwsLogsAdapter(client) await expect(adapter.documents.listItems('/floci/probe', 'nope')).resolves.toEqual({items: [], nextCursor: null}) }) test('the store advertises no item writes', () => { const {client} = stubClient({}) const adapter = new AwsLogsAdapter(client) expect(adapter.documents.putItem).toBeUndefined() expect(adapter.documents.deleteItem).toBeUndefined() expect(adapter.documents.queryItems).toBeUndefined() }) })