@proulr/bff-core 0.1.2 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@proulr/bff-core",
3
- "version": "0.1.2",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "exports": {
8
- ".": "./src/index.ts"
8
+ ".": "./src/index.ts",
9
+ "./testing": "./src/testing/index.ts"
9
10
  },
10
11
  "files": [
11
12
  "src"
@@ -15,22 +16,43 @@
15
16
  "url": "git+https://github.com/saviorbp/proulr-bff-core.git"
16
17
  },
17
18
  "scripts": {
19
+ "prepare": "husky",
18
20
  "typecheck": "tsc --noEmit",
19
- "prepublishOnly": "npm run typecheck"
21
+ "test": "vitest run",
22
+ "test:watch": "vitest",
23
+ "test:integration": "vitest run --config vitest.integration.config.ts",
24
+ "test:integration:coverage": "vitest run --config vitest.integration.config.ts --coverage",
25
+ "prepublishOnly": "npm run typecheck",
26
+ "lint": "eslint .",
27
+ "lint:fix": "eslint . --fix",
28
+ "version:sync": "node ../scripts/proulr-bump-and-sync-deps.mjs",
29
+ "test:coverage": "vitest run --coverage"
20
30
  },
21
31
  "publishConfig": {
22
32
  "access": "public"
23
33
  },
24
34
  "dependencies": {
25
35
  "@hono/node-server": "^1.19.6",
26
- "@proulr/sdui-contract": "^0.1.0",
36
+ "@proulr/rosetta": "^0.1.5",
37
+ "@proulr/sdui-contract": "^0.1.5",
27
38
  "@supabase/supabase-js": "^2.107.0",
28
39
  "dotenv": "^16.6.1",
29
40
  "hono": "^4.10.6",
30
41
  "prom-client": "^15.1.3"
31
42
  },
32
43
  "devDependencies": {
44
+ "@eslint/js": "^9.39.4",
33
45
  "@types/node": "^22.10.0",
34
- "typescript": "~6.0.3"
46
+ "@vitest/coverage-v8": "^3.2.7",
47
+ "eslint": "^9.0.0",
48
+ "globals": "^16.0.0",
49
+ "husky": "^9.1.7",
50
+ "lint-staged": "^16.4.0",
51
+ "typescript": "~6.0.3",
52
+ "typescript-eslint": "^8.61.1",
53
+ "vitest": "^3.2.7"
54
+ },
55
+ "engines": {
56
+ "node": ">=22"
35
57
  }
36
58
  }
@@ -0,0 +1,212 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ type ServeCallback = (info: { address: string; family: string; port: number }) => void;
4
+
5
+ const serveMock = vi.fn((opts: unknown, cb?: ServeCallback) => {
6
+ void opts;
7
+ cb?.({ address: '0.0.0.0', family: 'IPv4', port: 9999 });
8
+ return { close: vi.fn() };
9
+ });
10
+
11
+ vi.mock('@hono/node-server', () => ({
12
+ serve: (opts: unknown, cb?: ServeCallback) => serveMock(opts, cb),
13
+ }));
14
+
15
+ import { createSduiBff } from './index';
16
+
17
+ function createTestApp(overrides?: {
18
+ metricsEnv?: string;
19
+ actions?: Parameters<typeof createSduiBff>[0]['actions'];
20
+ }) {
21
+ if (overrides?.metricsEnv !== undefined) {
22
+ vi.stubEnv('BFF_METRICS', overrides.metricsEnv);
23
+ }
24
+ return createSduiBff({
25
+ name: 'Integration BFF',
26
+ port: 9999,
27
+ bffId: 'integration-bff',
28
+ screens: {
29
+ 'demo.home': async (ctx) => ({
30
+ schema_version: 1,
31
+ screen_id: 'demo.home',
32
+ blocks: [],
33
+ data: {
34
+ auth_token: ctx.authToken ?? null,
35
+ locale: ctx.query?.locale ?? null,
36
+ },
37
+ meta: { bff: 'integration-bff' },
38
+ }),
39
+ },
40
+ actions: overrides?.actions ?? {
41
+ ping: async (_ctx, body) => ({ echo: body }),
42
+ boom: async () => {
43
+ throw new Error('boom_failed');
44
+ },
45
+ boom_string: async () => {
46
+ throw 'string_error';
47
+ },
48
+ },
49
+ });
50
+ }
51
+
52
+ describe('createSduiBff HTTP integration', () => {
53
+ beforeEach(() => {
54
+ serveMock.mockClear();
55
+ vi.unstubAllEnvs();
56
+ });
57
+
58
+ afterEach(() => {
59
+ vi.unstubAllEnvs();
60
+ });
61
+
62
+ it('serves /health with screens and metrics flag', async () => {
63
+ const { app } = createTestApp({ metricsEnv: '1' });
64
+ const response = await app.request('/health');
65
+ expect(response.status).toBe(200);
66
+ expect(response.headers.get('X-Request-Id')).toBeTruthy();
67
+ await expect(response.json()).resolves.toMatchObject({
68
+ ok: true,
69
+ bff: 'integration-bff',
70
+ name: 'Integration BFF',
71
+ screens: ['demo.home'],
72
+ metrics_enabled: true,
73
+ });
74
+ });
75
+
76
+ it('returns screen wire and parses Bearer auth + query', async () => {
77
+ const { app } = createTestApp();
78
+ const response = await app.request('/v1/screens/demo.home?locale=pt-BR', {
79
+ headers: { Authorization: 'Bearer tok-abc ' },
80
+ });
81
+ expect(response.status).toBe(200);
82
+ const body = await response.json();
83
+ expect(body.screen_id).toBe('demo.home');
84
+ expect(body.data).toMatchObject({ auth_token: 'tok-abc', locale: 'pt-BR' });
85
+ });
86
+
87
+ it('ignores non-Bearer Authorization header', async () => {
88
+ const { app } = createTestApp();
89
+ const response = await app.request('/v1/screens/demo.home', {
90
+ headers: { Authorization: 'Basic abc' },
91
+ });
92
+ expect(response.status).toBe(200);
93
+ const body = await response.json();
94
+ expect(body.data.auth_token).toBeNull();
95
+ });
96
+
97
+ it('returns 404 for unknown screen', async () => {
98
+ const { app } = createTestApp();
99
+ const response = await app.request('/v1/screens/missing.screen');
100
+ expect(response.status).toBe(404);
101
+ await expect(response.json()).resolves.toMatchObject({
102
+ error: 'screen_not_found',
103
+ screen_id: 'missing.screen',
104
+ });
105
+ });
106
+
107
+ it('invokes registered action with auth and body', async () => {
108
+ const { app } = createTestApp();
109
+ const response = await app.request('/v1/actions/ping', {
110
+ method: 'POST',
111
+ headers: {
112
+ 'Content-Type': 'application/json',
113
+ Authorization: 'Bearer act-tok',
114
+ },
115
+ body: JSON.stringify({ n: 1 }),
116
+ });
117
+ expect(response.status).toBe(200);
118
+ await expect(response.json()).resolves.toMatchObject({
119
+ ok: true,
120
+ action_id: 'ping',
121
+ echo: { n: 1 },
122
+ });
123
+ });
124
+
125
+ it('returns 404 stub for unknown action', async () => {
126
+ const { app } = createTestApp({ actions: {} });
127
+ const response = await app.request('/v1/actions/noop', {
128
+ method: 'POST',
129
+ headers: { 'Content-Type': 'application/json' },
130
+ body: JSON.stringify({}),
131
+ });
132
+ expect(response.status).toBe(404);
133
+ await expect(response.json()).resolves.toMatchObject({
134
+ ok: false,
135
+ error: 'action_not_found',
136
+ action_id: 'noop',
137
+ });
138
+ });
139
+
140
+ it('returns 500 when action throws Error', async () => {
141
+ const { app } = createTestApp();
142
+ const response = await app.request('/v1/actions/boom', {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ body: JSON.stringify({}),
146
+ });
147
+ expect(response.status).toBe(500);
148
+ await expect(response.json()).resolves.toMatchObject({
149
+ ok: false,
150
+ error: 'action_failed',
151
+ message: 'boom_failed',
152
+ action_id: 'boom',
153
+ });
154
+ });
155
+
156
+ it('returns 500 with fallback message for non-Error throw', async () => {
157
+ const { app } = createTestApp();
158
+ const response = await app.request('/v1/actions/boom_string', {
159
+ method: 'POST',
160
+ headers: { 'Content-Type': 'application/json' },
161
+ body: '{}',
162
+ });
163
+ expect(response.status).toBe(500);
164
+ await expect(response.json()).resolves.toMatchObject({
165
+ ok: false,
166
+ error: 'action_failed',
167
+ message: 'action_failed',
168
+ });
169
+ });
170
+
171
+ it('accepts invalid JSON action body as empty object', async () => {
172
+ const { app } = createTestApp();
173
+ const response = await app.request('/v1/actions/ping', {
174
+ method: 'POST',
175
+ headers: { 'Content-Type': 'application/json' },
176
+ body: '{not-json',
177
+ });
178
+ expect(response.status).toBe(200);
179
+ await expect(response.json()).resolves.toMatchObject({
180
+ ok: true,
181
+ echo: {},
182
+ });
183
+ });
184
+
185
+ it('serves /metrics when metrics enabled', async () => {
186
+ const { app } = createTestApp({ metricsEnv: '1' });
187
+ await app.request('/health');
188
+ const response = await app.request('/metrics');
189
+ expect(response.status).toBe(200);
190
+ expect(response.headers.get('Content-Type')).toContain('text/plain');
191
+ const text = await response.text();
192
+ expect(text).toContain('bff_http_requests_total');
193
+ });
194
+
195
+ it('does not register /metrics when BFF_METRICS=0', async () => {
196
+ const { app } = createTestApp({ metricsEnv: '0' });
197
+ const health = await app.request('/health');
198
+ await expect(health.json()).resolves.toMatchObject({ metrics_enabled: false });
199
+ const metrics = await app.request('/metrics');
200
+ expect(metrics.status).toBe(404);
201
+ });
202
+
203
+ it('start() binds via @hono/node-server serve', () => {
204
+ const { start } = createTestApp({ metricsEnv: '0' });
205
+ start();
206
+ expect(serveMock).toHaveBeenCalledOnce();
207
+ expect(serveMock.mock.calls[0]?.[0]).toMatchObject({
208
+ port: 9999,
209
+ hostname: '0.0.0.0',
210
+ });
211
+ });
212
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createScreenBuilderContext } from './testing/create-screen-builder-context';
3
+ import { createMockSupabaseClient } from './testing/create-mock-supabase-client';
4
+ import { createTestSduiBff } from './testing/create-test-sdui-bff';
5
+
6
+ describe('testing helpers', () => {
7
+ it('createScreenBuilderContext defaults empty maps', () => {
8
+ expect(createScreenBuilderContext({ authToken: 'a' })).toEqual({
9
+ authToken: 'a',
10
+ query: {},
11
+ routeParams: {},
12
+ });
13
+ });
14
+
15
+ it('createMockSupabaseClient resolves table and rpc results', async () => {
16
+ const client = createMockSupabaseClient({
17
+ fromResults: {
18
+ profiles: { data: { id: '1' }, error: null },
19
+ },
20
+ rpcResults: {
21
+ ping: { data: true, error: null },
22
+ },
23
+ });
24
+
25
+ const profile = await client.from('profiles').select('*').eq('id', '1').maybeSingle();
26
+ expect(profile.data).toEqual({ id: '1' });
27
+
28
+ const rpc = await client.rpc('ping');
29
+ expect(rpc.data).toBe(true);
30
+ });
31
+
32
+ it('createTestSduiBff exposes app.request without start', async () => {
33
+ const { app } = createTestSduiBff({
34
+ name: 'Unit',
35
+ port: 1,
36
+ bffId: 'unit',
37
+ screens: {},
38
+ });
39
+ const response = await app.request('/health');
40
+ expect(response.status).toBe(200);
41
+ });
42
+ });
package/src/index.ts CHANGED
@@ -161,3 +161,7 @@ export {
161
161
  type InsertRandomNativeAdSlotsOptions,
162
162
  type NativeAdSlotWire,
163
163
  } from './native-ad-slot-insertion';
164
+ export {
165
+ resolveLocaleFromQuery,
166
+ type ProulrLocale,
167
+ } from '@proulr/rosetta';
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ buildNativeAdInsertionSeed,
4
+ insertRandomNativeAdSlots,
5
+ } from './native-ad-slot-insertion';
6
+
7
+ describe('buildNativeAdInsertionSeed', () => {
8
+ it('joins non-empty parts with colon', () => {
9
+ expect(buildNativeAdInsertionSeed(['feed', '', 'user-1'])).toBe('feed:user-1');
10
+ });
11
+ });
12
+
13
+ describe('insertRandomNativeAdSlots', () => {
14
+ it('returns a copy when feed is too short for ads', () => {
15
+ const items = [1, 2];
16
+ const result = insertRandomNativeAdSlots(items, {
17
+ placement: 'feed',
18
+ seed: 'a',
19
+ minContentBeforeFirstAd: 2,
20
+ });
21
+ expect(result).toEqual([1, 2]);
22
+ expect(result).not.toBe(items);
23
+ });
24
+
25
+ it('is deterministic for the same seed', () => {
26
+ const items = Array.from({ length: 40 }, (_, index) => index);
27
+ const options = {
28
+ placement: 'discover',
29
+ seed: 'stable-seed',
30
+ minContentBeforeFirstAd: 2,
31
+ minGap: 4,
32
+ maxGap: 6,
33
+ maxAds: 3,
34
+ };
35
+ const first = insertRandomNativeAdSlots(items, options);
36
+ const second = insertRandomNativeAdSlots(items, options);
37
+ expect(first).toEqual(second);
38
+ expect(first.some((item) => typeof item === 'object' && item !== null && 'kind' in item && item.kind === 'ad')).toBe(
39
+ true,
40
+ );
41
+ });
42
+ });
@@ -0,0 +1,124 @@
1
+ export type MockQueryResult<T = unknown> = {
2
+ data: T;
3
+ error: { message: string; code?: string } | null;
4
+ count?: number | null;
5
+ status?: number;
6
+ statusText?: string;
7
+ };
8
+
9
+ type ThenableResult<T> = PromiseLike<MockQueryResult<T>> & MockQueryBuilder;
10
+
11
+ export type MockQueryBuilder = {
12
+ select: (...args: unknown[]) => MockQueryBuilder;
13
+ insert: (...args: unknown[]) => MockQueryBuilder;
14
+ update: (...args: unknown[]) => MockQueryBuilder;
15
+ upsert: (...args: unknown[]) => MockQueryBuilder;
16
+ delete: (...args: unknown[]) => MockQueryBuilder;
17
+ eq: (...args: unknown[]) => MockQueryBuilder;
18
+ neq: (...args: unknown[]) => MockQueryBuilder;
19
+ in: (...args: unknown[]) => MockQueryBuilder;
20
+ is: (...args: unknown[]) => MockQueryBuilder;
21
+ gt: (...args: unknown[]) => MockQueryBuilder;
22
+ gte: (...args: unknown[]) => MockQueryBuilder;
23
+ lt: (...args: unknown[]) => MockQueryBuilder;
24
+ lte: (...args: unknown[]) => MockQueryBuilder;
25
+ like: (...args: unknown[]) => MockQueryBuilder;
26
+ ilike: (...args: unknown[]) => MockQueryBuilder;
27
+ order: (...args: unknown[]) => MockQueryBuilder;
28
+ limit: (...args: unknown[]) => MockQueryBuilder;
29
+ range: (...args: unknown[]) => MockQueryBuilder;
30
+ single: () => ThenableResult<unknown>;
31
+ maybeSingle: () => ThenableResult<unknown>;
32
+ then: PromiseLike<MockQueryResult>['then'];
33
+ };
34
+
35
+ export type MockSupabaseClientOptions = {
36
+ /** Default result for any chain that resolves without a table-specific override. */
37
+ defaultResult?: MockQueryResult;
38
+ /** Per-table results keyed by table name passed to `.from(table)`. */
39
+ fromResults?: Record<string, MockQueryResult | (() => MockQueryResult)>;
40
+ /** Results keyed by RPC function name. */
41
+ rpcResults?: Record<string, MockQueryResult | ((args?: unknown) => MockQueryResult)>;
42
+ };
43
+
44
+ function resolveResult(
45
+ value: MockQueryResult | (() => MockQueryResult) | undefined,
46
+ fallback: MockQueryResult,
47
+ ): MockQueryResult {
48
+ if (!value) return fallback;
49
+ return typeof value === 'function' ? value() : value;
50
+ }
51
+
52
+ function createQueryBuilder(result: MockQueryResult): MockQueryBuilder {
53
+ const builder: MockQueryBuilder = {} as MockQueryBuilder;
54
+
55
+ const chain = (): MockQueryBuilder => builder;
56
+ const terminal = (): ThenableResult<unknown> =>
57
+ Object.assign(Promise.resolve(result), builder) as ThenableResult<unknown>;
58
+
59
+ builder.select = chain;
60
+ builder.insert = chain;
61
+ builder.update = chain;
62
+ builder.upsert = chain;
63
+ builder.delete = chain;
64
+ builder.eq = chain;
65
+ builder.neq = chain;
66
+ builder.in = chain;
67
+ builder.is = chain;
68
+ builder.gt = chain;
69
+ builder.gte = chain;
70
+ builder.lt = chain;
71
+ builder.lte = chain;
72
+ builder.like = chain;
73
+ builder.ilike = chain;
74
+ builder.order = chain;
75
+ builder.limit = chain;
76
+ builder.range = chain;
77
+ builder.single = terminal;
78
+ builder.maybeSingle = terminal;
79
+ builder.then = (onfulfilled, onrejected) =>
80
+ Promise.resolve(result).then(onfulfilled, onrejected);
81
+
82
+ return builder;
83
+ }
84
+
85
+ export type MockSupabaseClient = {
86
+ from: (table: string) => MockQueryBuilder;
87
+ rpc: (fn: string, args?: unknown) => ThenableResult<unknown>;
88
+ auth: {
89
+ getUser: () => Promise<{ data: { user: { id: string } | null }; error: null }>;
90
+ };
91
+ };
92
+
93
+ /**
94
+ * Lightweight chainable Supabase stub for BFF unit/integration tests.
95
+ * Configure per-table / RPC results; unresolved tables use `defaultResult`.
96
+ */
97
+ export function createMockSupabaseClient(
98
+ options: MockSupabaseClientOptions = {},
99
+ ): MockSupabaseClient {
100
+ const defaultResult: MockQueryResult = options.defaultResult ?? {
101
+ data: null,
102
+ error: null,
103
+ };
104
+
105
+ return {
106
+ from(table: string) {
107
+ const tableResult = resolveResult(options.fromResults?.[table], defaultResult);
108
+ return createQueryBuilder(tableResult);
109
+ },
110
+ rpc(fn: string, args?: unknown) {
111
+ const rpcEntry = options.rpcResults?.[fn];
112
+ const result =
113
+ typeof rpcEntry === 'function'
114
+ ? rpcEntry(args)
115
+ : (rpcEntry ?? defaultResult);
116
+ return Object.assign(Promise.resolve(result), createQueryBuilder(result)) as ThenableResult<unknown>;
117
+ },
118
+ auth: {
119
+ async getUser() {
120
+ return { data: { user: { id: 'test-user-id' } }, error: null };
121
+ },
122
+ },
123
+ };
124
+ }
@@ -0,0 +1,17 @@
1
+ import type { SduiScreenBuilderContext } from '@proulr/sdui-contract';
2
+
3
+ export type CreateScreenBuilderContextOptions = {
4
+ authToken?: string;
5
+ query?: Record<string, string>;
6
+ routeParams?: Record<string, string>;
7
+ };
8
+
9
+ export function createScreenBuilderContext(
10
+ options: CreateScreenBuilderContextOptions = {},
11
+ ): SduiScreenBuilderContext {
12
+ return {
13
+ authToken: options.authToken,
14
+ query: options.query ?? {},
15
+ routeParams: options.routeParams ?? {},
16
+ };
17
+ }
@@ -0,0 +1,6 @@
1
+ import { createSduiBff, type CreateSduiBffOptions } from '../index';
2
+
3
+ /** Thin wrapper — same as `createSduiBff`; use `app.request` without calling `start()`. */
4
+ export function createTestSduiBff(options: CreateSduiBffOptions) {
5
+ return createSduiBff(options);
6
+ }
@@ -0,0 +1,12 @@
1
+ export {
2
+ createMockSupabaseClient,
3
+ type MockQueryBuilder,
4
+ type MockQueryResult,
5
+ type MockSupabaseClient,
6
+ type MockSupabaseClientOptions,
7
+ } from './create-mock-supabase-client';
8
+ export {
9
+ createScreenBuilderContext,
10
+ type CreateScreenBuilderContextOptions,
11
+ } from './create-screen-builder-context';
12
+ export { createTestSduiBff } from './create-test-sdui-bff';