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/fastify-core": "workspace:*",
|
||||||
"@bytelyst/llm-router": "workspace:*",
|
"@bytelyst/llm-router": "workspace:*",
|
||||||
"@bytelyst/logger": "workspace:*",
|
"@bytelyst/logger": "workspace:*",
|
||||||
|
"@bytelyst/push": "workspace:*",
|
||||||
"@fastify/cors": "^10.0.2",
|
"@fastify/cors": "^10.0.2",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
"jose": "^6.0.11",
|
"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/sessions/routes.js', () => ({ sessionRoutes: vi.fn() }));
|
||||||
vi.mock('./modules/plugins/routes.js', () => ({ pluginRoutes: vi.fn() }));
|
vi.mock('./modules/plugins/routes.js', () => ({ pluginRoutes: vi.fn() }));
|
||||||
vi.mock('./modules/schedule/routes.js', () => ({ scheduleRoutes: vi.fn() }));
|
vi.mock('./modules/schedule/routes.js', () => ({ scheduleRoutes: vi.fn() }));
|
||||||
|
vi.mock('./modules/push/routes.js', () => ({ pushRoutes: vi.fn() }));
|
||||||
|
|
||||||
describe('cowork-service bootstrap', () => {
|
describe('cowork-service bootstrap', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -101,9 +102,9 @@ describe('cowork-service bootstrap', () => {
|
|||||||
expect(opts.version).toBe('0.1.0');
|
expect(opts.version).toBe('0.1.0');
|
||||||
expect(opts.readiness).toBe(true);
|
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(registerOptionalJwtContextMock).toHaveBeenCalledOnce();
|
||||||
expect(appMock.register).toHaveBeenCalledTimes(11);
|
expect(appMock.register).toHaveBeenCalledTimes(12);
|
||||||
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4009, host: '0.0.0.0' });
|
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 { sessionRoutes } from './modules/sessions/routes.js';
|
||||||
import { pluginRoutes } from './modules/plugins/routes.js';
|
import { pluginRoutes } from './modules/plugins/routes.js';
|
||||||
import { scheduleRoutes } from './modules/schedule/routes.js';
|
import { scheduleRoutes } from './modules/schedule/routes.js';
|
||||||
|
import { pushRoutes } from './modules/push/routes.js';
|
||||||
import type { JwtPayload } from './lib/request-context.js';
|
import type { JwtPayload } from './lib/request-context.js';
|
||||||
|
|
||||||
const jwtSecret = new TextEncoder().encode(config.JWT_SECRET);
|
const jwtSecret = new TextEncoder().encode(config.JWT_SECRET);
|
||||||
@ -70,6 +71,7 @@ await app.register(marketplaceRoutes);
|
|||||||
await app.register(sessionRoutes);
|
await app.register(sessionRoutes);
|
||||||
await app.register(pluginRoutes);
|
await app.register(pluginRoutes);
|
||||||
await app.register(scheduleRoutes);
|
await app.register(scheduleRoutes);
|
||||||
|
await app.register(pushRoutes);
|
||||||
|
|
||||||
// Bootstrap endpoint (same pattern as FlowMonk, ActionTrail, etc.)
|
// Bootstrap endpoint (same pattern as FlowMonk, ActionTrail, etc.)
|
||||||
app.get('/api/bootstrap', async () => ({
|
app.get('/api/bootstrap', async () => ({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user