feat(cowork-service): H.9 push notification stubs

This commit is contained in:
Saravana Achu Mac 2026-04-03 13:55:10 -07:00
parent 2f8fe13c43
commit 5c08832a3e
5 changed files with 169 additions and 2 deletions

View File

@ -23,6 +23,7 @@
"@bytelyst/fastify-core": "workspace:*",
"@bytelyst/llm-router": "workspace:*",
"@bytelyst/logger": "workspace:*",
"@bytelyst/push": "workspace:*",
"@fastify/cors": "^10.0.2",
"fastify": "^5.2.1",
"jose": "^6.0.11",

View File

@ -0,0 +1,94 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createServiceApp, type FastifyApp } from '@bytelyst/fastify-core';
vi.mock('../../lib/config.js', () => ({
config: {
SERVICE_NAME: 'cowork-service',
PLATFORM_SERVICE_URL: 'http://mock-platform:4003',
},
}));
vi.mock('../../lib/product-config.js', () => ({
PRODUCT_ID: 'clawcowork',
}));
vi.mock('../../lib/request-context.js', () => ({
getUserId: vi.fn(() => 'demo-user'),
}));
import { pushRoutes } from './routes.js';
let app: FastifyApp;
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
beforeAll(async () => {
app = await createServiceApp({
name: 'push-routes-test',
version: '0.0.1',
logger: false,
});
await app.register(pushRoutes);
});
afterAll(async () => {
await app.close();
});
beforeEach(() => {
mockFetch.mockReset();
});
describe('push proxy routes', () => {
it('POST /api/push/register proxies push device registration', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ registered: true }),
});
const res = await app.inject({
method: 'POST',
url: '/api/push/register',
headers: { authorization: 'Bearer push-token' },
payload: { deviceToken: 'device-token', platform: 'ios' },
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload).registered).toBe(true);
expect(mockFetch.mock.calls[0][0]).toBe(
'http://mock-platform:4003/notifications/push/register'
);
expect(JSON.parse(mockFetch.mock.calls[0][1].body)).toMatchObject({
userId: 'demo-user',
productId: 'clawcowork',
deviceToken: 'device-token',
});
});
it('DELETE /api/push/register proxies push unregistration', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ removed: true }),
});
const res = await app.inject({
method: 'DELETE',
url: '/api/push/register',
payload: { deviceToken: 'device-token' },
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload).removed).toBe(true);
expect(mockFetch.mock.calls[0][1].method).toBe('DELETE');
});
it('returns 502 when platform-service is unavailable', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const res = await app.inject({
method: 'POST',
url: '/api/push/register',
payload: { deviceToken: 'device-token' },
});
expect(res.statusCode).toBe(502);
});
});

View File

@ -0,0 +1,69 @@
import type { FastifyInstance } from 'fastify';
import { config } from '../../lib/config.js';
import { PRODUCT_ID } from '../../lib/product-config.js';
import { getUserId } from '../../lib/request-context.js';
function buildHeaders(req: import('fastify').FastifyRequest): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-product-id': PRODUCT_ID,
'x-request-id': req.id,
};
const auth = req.headers.authorization;
if (typeof auth === 'string') headers.authorization = auth;
return headers;
}
export async function pushRoutes(app: FastifyInstance) {
const platformUrl = config.PLATFORM_SERVICE_URL;
app.post('/api/push/register', async (req, reply) => {
const userId = getUserId(req);
try {
const res = await fetch(`${platformUrl}/notifications/push/register`, {
method: 'POST',
headers: buildHeaders(req),
body: JSON.stringify({
userId,
productId: PRODUCT_ID,
...(req.body as Record<string, unknown>),
}),
});
if (!res.ok) {
reply.code(res.status);
return { error: `Platform returned ${res.status}` };
}
return res.json();
} catch (err) {
req.log.warn({ err }, 'Failed to proxy push registration');
reply.code(502);
return { error: 'Platform-service unavailable' };
}
});
app.delete('/api/push/register', async (req, reply) => {
const userId = getUserId(req);
try {
const res = await fetch(`${platformUrl}/notifications/push/register`, {
method: 'DELETE',
headers: buildHeaders(req),
body: JSON.stringify({
userId,
productId: PRODUCT_ID,
...(req.body as Record<string, unknown>),
}),
});
if (!res.ok) {
reply.code(res.status);
return { error: `Platform returned ${res.status}` };
}
return res.json();
} catch (err) {
req.log.warn({ err }, 'Failed to proxy push unregistration');
reply.code(502);
return { error: 'Platform-service unavailable' };
}
});
}

View File

@ -82,6 +82,7 @@ vi.mock('./modules/marketplace/routes.js', () => ({ marketplaceRoutes: vi.fn() }
vi.mock('./modules/sessions/routes.js', () => ({ sessionRoutes: vi.fn() }));
vi.mock('./modules/plugins/routes.js', () => ({ pluginRoutes: vi.fn() }));
vi.mock('./modules/schedule/routes.js', () => ({ scheduleRoutes: vi.fn() }));
vi.mock('./modules/push/routes.js', () => ({ pushRoutes: vi.fn() }));
describe('cowork-service bootstrap', () => {
beforeEach(() => {
@ -101,9 +102,9 @@ describe('cowork-service bootstrap', () => {
expect(opts.version).toBe('0.1.0');
expect(opts.readiness).toBe(true);
// health + task + llm + audit + usage + notifications + extraction + marketplace + sessions + plugins + schedule = 11 register calls + 1 JWT
// health + task + llm + audit + usage + notifications + extraction + marketplace + sessions + plugins + schedule + push = 12 register calls + 1 JWT
expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce();
expect(appMock.register).toHaveBeenCalledTimes(11);
expect(appMock.register).toHaveBeenCalledTimes(12);
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4009, host: '0.0.0.0' });
});
});

View File

@ -33,6 +33,7 @@ import { marketplaceRoutes } from './modules/marketplace/routes.js';
import { sessionRoutes } from './modules/sessions/routes.js';
import { pluginRoutes } from './modules/plugins/routes.js';
import { scheduleRoutes } from './modules/schedule/routes.js';
import { pushRoutes } from './modules/push/routes.js';
import type { JwtPayload } from './lib/request-context.js';
const jwtSecret = new TextEncoder().encode(config.JWT_SECRET);
@ -70,6 +71,7 @@ await app.register(marketplaceRoutes);
await app.register(sessionRoutes);
await app.register(pluginRoutes);
await app.register(scheduleRoutes);
await app.register(pushRoutes);
// Bootstrap endpoint (same pattern as FlowMonk, ActionTrail, etc.)
app.get('/api/bootstrap', async () => ({