feat(cowork-service): H.9 push notification stubs
This commit is contained in:
parent
2f8fe13c43
commit
5c08832a3e
@ -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",
|
||||
|
||||
94
services/cowork-service/src/modules/push/routes.test.ts
Normal file
94
services/cowork-service/src/modules/push/routes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
69
services/cowork-service/src/modules/push/routes.ts
Normal file
69
services/cowork-service/src/modules/push/routes.ts
Normal 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' };
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 () => ({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user