mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
230 lines
7.1 KiB
TypeScript
230 lines
7.1 KiB
TypeScript
import { createHash } from 'node:crypto'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { FcmClient, type FcmRequest, type FcmResponse } from './fcm-client.js'
|
|
import { buildPushDelivery } from './push-delivery-message.js'
|
|
|
|
const NOW = 1_700_000_000_000
|
|
const HOST = 'abcdefghijklmnop'
|
|
const TOKEN = 'cQ1abcDEF_gh:APA91bZZ-zz0123456789abcdefghijklmnopqrstuvwxyz'
|
|
|
|
function delivery(agentState: 'needs-input' | null = 'needs-input') {
|
|
return buildPushDelivery({
|
|
expiresAt: NOW + 300_000,
|
|
registrationId: 'reg-1',
|
|
hostFingerprint: HOST,
|
|
notification: {
|
|
notificationId: 'note-1',
|
|
notificationSeq: 7,
|
|
notificationEpoch: 'epoch-1',
|
|
source: 'agent-task-complete',
|
|
agentState,
|
|
title: 'Agent needs input',
|
|
body: 'Waiting on your answer',
|
|
paneKey: 'tab-b:pane-1',
|
|
worktreeId: 'wt-1'
|
|
}
|
|
})
|
|
}
|
|
|
|
function fakeTransport(response: FcmResponse) {
|
|
const requests: FcmRequest[] = []
|
|
return {
|
|
requests,
|
|
transport: async (request: FcmRequest): Promise<FcmResponse> => {
|
|
requests.push(request)
|
|
return response
|
|
}
|
|
}
|
|
}
|
|
|
|
function client(response: FcmResponse) {
|
|
const fake = fakeTransport(response)
|
|
return {
|
|
fake,
|
|
client: new FcmClient({
|
|
projectId: 'onorca-cloud',
|
|
now: () => NOW,
|
|
accessToken: async () => 'access-token',
|
|
transport: fake.transport
|
|
})
|
|
}
|
|
}
|
|
|
|
describe('fcm client', () => {
|
|
it('posts the v1 send payload for the configured project', async () => {
|
|
const { fake, client: fcm } = client({ status: 200, body: '{"name":"projects/x/messages/1"}' })
|
|
await expect(fcm.send(delivery(), { token: TOKEN })).resolves.toEqual({ status: 'sent' })
|
|
const request = fake.requests[0]!
|
|
expect(request.url).toBe('https://fcm.googleapis.com/v1/projects/onorca-cloud/messages:send')
|
|
expect(request.accessToken).toBe('access-token')
|
|
expect(JSON.parse(request.body)).toEqual({
|
|
message: {
|
|
token: TOKEN,
|
|
android: { priority: 'HIGH', ttl: '300s' },
|
|
data: {
|
|
title: 'Agent needs input',
|
|
message: 'Waiting on your answer',
|
|
tag: delivery().collapseId,
|
|
channelId: 'orca-desktop',
|
|
hostFingerprint: HOST,
|
|
paneKey: 'tab-b:pane-1',
|
|
worktreeId: 'wt-1',
|
|
notificationId: 'note-1',
|
|
notificationSeq: '7',
|
|
notificationEpoch: 'epoch-1',
|
|
source: 'agent-task-complete',
|
|
agentState: 'needs-input'
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
it('carries every data value as a string and omits a null agent state', async () => {
|
|
const { fake, client: fcm } = client({ status: 200, body: '{}' })
|
|
await fcm.send(delivery(null), { token: TOKEN })
|
|
const message = JSON.parse(fake.requests[0]!.body) as {
|
|
message: {
|
|
android: Record<string, unknown>
|
|
data: Record<string, string>
|
|
}
|
|
}
|
|
expect(Object.values(message.message.data).every((value) => typeof value === 'string')).toBe(
|
|
true
|
|
)
|
|
expect(message.message.data.agentState).toBeUndefined()
|
|
const tag = createHash('sha256')
|
|
.update(JSON.stringify([HOST, 'note-1']))
|
|
.digest('hex')
|
|
expect(message.message.data.coalescedCount).toBeUndefined()
|
|
expect(message.message.data.tag).toBe(tag)
|
|
expect(message.message.android).not.toHaveProperty('collapse_key')
|
|
expect(message.message).not.toHaveProperty('notification')
|
|
expect(message.message.data).not.toHaveProperty('body')
|
|
})
|
|
|
|
it('marks an unregistered token dead from the status or the error detail', async () => {
|
|
const byStatus = client({
|
|
status: 404,
|
|
body: JSON.stringify({ error: { status: 'UNREGISTERED', message: 'not registered' } })
|
|
})
|
|
await expect(byStatus.client.send(delivery(), { token: TOKEN })).resolves.toEqual({
|
|
status: 'dead',
|
|
reason: 'UNREGISTERED'
|
|
})
|
|
const byDetail = client({
|
|
status: 404,
|
|
body: JSON.stringify({
|
|
error: {
|
|
status: 'NOT_FOUND',
|
|
message: 'Requested entity was not found.',
|
|
details: [{ errorCode: 'UNREGISTERED' }]
|
|
}
|
|
})
|
|
})
|
|
await expect(byDetail.client.send(delivery(), { token: TOKEN })).resolves.toEqual({
|
|
status: 'dead',
|
|
reason: 'UNREGISTERED'
|
|
})
|
|
})
|
|
|
|
it('marks an invalid-argument that names the token dead, and others an error', async () => {
|
|
const named = client({
|
|
status: 400,
|
|
body: JSON.stringify({
|
|
error: { status: 'INVALID_ARGUMENT', message: 'The registration token is not valid.' }
|
|
})
|
|
})
|
|
await expect(named.client.send(delivery(), { token: TOKEN })).resolves.toEqual({
|
|
status: 'dead',
|
|
reason: 'INVALID_ARGUMENT'
|
|
})
|
|
const unnamed = client({
|
|
status: 400,
|
|
body: JSON.stringify({
|
|
error: { status: 'INVALID_ARGUMENT', message: 'Invalid value at message.android.ttl' }
|
|
})
|
|
})
|
|
await expect(unnamed.client.send(delivery(), { token: TOKEN })).resolves.toEqual({
|
|
status: 'error',
|
|
reason: 'INVALID_ARGUMENT',
|
|
retryable: false,
|
|
retryAfterMs: 10000
|
|
})
|
|
})
|
|
|
|
it('treats a server fault and a transport failure as errors', async () => {
|
|
const faulted = client({
|
|
status: 503,
|
|
body: JSON.stringify({ error: { status: 'UNAVAILABLE', message: 'backend busy' } })
|
|
})
|
|
await expect(faulted.client.send(delivery(), { token: TOKEN })).resolves.toEqual({
|
|
status: 'error',
|
|
reason: 'UNAVAILABLE',
|
|
retryable: true,
|
|
retryAfterMs: 10000
|
|
})
|
|
const broken = new FcmClient({
|
|
projectId: 'onorca-cloud',
|
|
now: () => NOW,
|
|
accessToken: async () => 'access-token',
|
|
transport: async () => {
|
|
throw new Error('ECONNRESET')
|
|
}
|
|
})
|
|
await expect(broken.send(delivery(), { token: TOKEN })).resolves.toEqual({
|
|
status: 'error',
|
|
reason: 'Error',
|
|
retryable: true
|
|
})
|
|
})
|
|
})
|
|
|
|
it('does not send when credential refresh crosses the absolute expiry', async () => {
|
|
let now = 1000
|
|
const fake = fakeTransport({ status: 200, body: '{}' })
|
|
const fcm = new FcmClient({
|
|
projectId: 'test',
|
|
now: () => now,
|
|
accessToken: async () => {
|
|
now = 3000
|
|
return 'test-token'
|
|
},
|
|
transport: fake.transport
|
|
})
|
|
await expect(fcm.send({ ...delivery(), expiresAt: 2000 }, { token: TOKEN })).resolves.toEqual({
|
|
status: 'error',
|
|
reason: 'expired'
|
|
})
|
|
expect(fake.requests).toHaveLength(0)
|
|
})
|
|
|
|
it('decreases retry TTL and refuses expired delivery before refreshing credentials', async () => {
|
|
let now = NOW
|
|
let refreshes = 0
|
|
const fake = fakeTransport({ status: 503, body: '{}' })
|
|
const fcm = new FcmClient({
|
|
projectId: 'test',
|
|
now: () => now,
|
|
accessToken: async () => {
|
|
refreshes++
|
|
return 'test-token'
|
|
},
|
|
transport: fake.transport
|
|
})
|
|
const pending = delivery()
|
|
await fcm.send(pending, { token: TOKEN })
|
|
now += 60_000
|
|
await fcm.send(pending, { token: TOKEN })
|
|
expect(fake.requests.map((request) => JSON.parse(request.body).message.android.ttl)).toEqual([
|
|
'300s',
|
|
'240s'
|
|
])
|
|
now = pending.expiresAt
|
|
await expect(fcm.send(pending, { token: TOKEN })).resolves.toEqual({
|
|
status: 'error',
|
|
reason: 'expired'
|
|
})
|
|
expect(fake.requests).toHaveLength(2)
|
|
expect(refreshes).toBe(2)
|
|
})
|