@proulr/bff-core 0.1.2 → 0.1.5
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 +23 -5
- package/src/index.test.ts +117 -0
- package/src/index.ts +4 -0
- package/src/native-ad-slot-insertion.test.ts +42 -0
- package/src/testing/create-mock-supabase-client.ts +124 -0
- package/src/testing/create-screen-builder-context.ts +17 -0
- package/src/testing/create-test-sdui-bff.ts +6 -0
- package/src/testing/index.ts +12 -0
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proulr/bff-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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,39 @@
|
|
|
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
|
-
"
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"test:watch": "vitest",
|
|
23
|
+
"prepublishOnly": "npm run typecheck",
|
|
24
|
+
"lint": "eslint .",
|
|
25
|
+
"lint:fix": "eslint . --fix",
|
|
26
|
+
"version:sync": "node ../scripts/proulr-bump-and-sync-deps.mjs"
|
|
20
27
|
},
|
|
21
28
|
"publishConfig": {
|
|
22
29
|
"access": "public"
|
|
23
30
|
},
|
|
24
31
|
"dependencies": {
|
|
25
32
|
"@hono/node-server": "^1.19.6",
|
|
26
|
-
"@proulr/
|
|
33
|
+
"@proulr/rosetta": "^0.1.4",
|
|
34
|
+
"@proulr/sdui-contract": "^0.1.5",
|
|
27
35
|
"@supabase/supabase-js": "^2.107.0",
|
|
28
36
|
"dotenv": "^16.6.1",
|
|
29
37
|
"hono": "^4.10.6",
|
|
30
38
|
"prom-client": "^15.1.3"
|
|
31
39
|
},
|
|
32
40
|
"devDependencies": {
|
|
41
|
+
"@eslint/js": "^9.39.4",
|
|
33
42
|
"@types/node": "^22.10.0",
|
|
34
|
-
"
|
|
43
|
+
"eslint": "^9.0.0",
|
|
44
|
+
"globals": "^16.0.0",
|
|
45
|
+
"husky": "^9.1.7",
|
|
46
|
+
"lint-staged": "^16.4.0",
|
|
47
|
+
"typescript": "~6.0.3",
|
|
48
|
+
"typescript-eslint": "^8.61.1",
|
|
49
|
+
"vitest": "^3.2.7"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=22"
|
|
35
53
|
}
|
|
36
54
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createSduiBff } from './index';
|
|
3
|
+
import { createScreenBuilderContext } from './testing/create-screen-builder-context';
|
|
4
|
+
import { createMockSupabaseClient } from './testing/create-mock-supabase-client';
|
|
5
|
+
|
|
6
|
+
describe('createSduiBff', () => {
|
|
7
|
+
it('serves /health with registered screens', async () => {
|
|
8
|
+
const { app } = createSduiBff({
|
|
9
|
+
name: 'Test BFF',
|
|
10
|
+
port: 9999,
|
|
11
|
+
bffId: 'test-bff',
|
|
12
|
+
screens: {
|
|
13
|
+
'demo.home': async () => ({
|
|
14
|
+
schema_version: 1,
|
|
15
|
+
screen_id: 'demo.home',
|
|
16
|
+
blocks: [],
|
|
17
|
+
}),
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const response = await app.request('/health');
|
|
22
|
+
expect(response.status).toBe(200);
|
|
23
|
+
const body = await response.json();
|
|
24
|
+
expect(body).toMatchObject({
|
|
25
|
+
ok: true,
|
|
26
|
+
bff: 'test-bff',
|
|
27
|
+
name: 'Test BFF',
|
|
28
|
+
screens: ['demo.home'],
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns 404 for unknown screen', async () => {
|
|
33
|
+
const { app } = createSduiBff({
|
|
34
|
+
name: 'Test BFF',
|
|
35
|
+
port: 9999,
|
|
36
|
+
bffId: 'test-bff',
|
|
37
|
+
screens: {},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const response = await app.request('/v1/screens/missing');
|
|
41
|
+
expect(response.status).toBe(404);
|
|
42
|
+
const body = await response.json();
|
|
43
|
+
expect(body).toMatchObject({ error: 'screen_not_found', screen_id: 'missing' });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns 404 stub for unknown action', async () => {
|
|
47
|
+
const { app } = createSduiBff({
|
|
48
|
+
name: 'Test BFF',
|
|
49
|
+
port: 9999,
|
|
50
|
+
bffId: 'test-bff',
|
|
51
|
+
screens: {},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const response = await app.request('/v1/actions/noop', {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'Content-Type': 'application/json' },
|
|
57
|
+
body: JSON.stringify({}),
|
|
58
|
+
});
|
|
59
|
+
expect(response.status).toBe(404);
|
|
60
|
+
const body = await response.json();
|
|
61
|
+
expect(body).toMatchObject({ ok: false, error: 'action_not_found', action_id: 'noop' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('invokes registered screen builder with auth token', async () => {
|
|
65
|
+
let seenToken: string | undefined;
|
|
66
|
+
const { app } = createSduiBff({
|
|
67
|
+
name: 'Test BFF',
|
|
68
|
+
port: 9999,
|
|
69
|
+
bffId: 'test-bff',
|
|
70
|
+
screens: {
|
|
71
|
+
'demo.home': async (ctx) => {
|
|
72
|
+
seenToken = ctx.authToken;
|
|
73
|
+
return {
|
|
74
|
+
schema_version: 1,
|
|
75
|
+
screen_id: 'demo.home',
|
|
76
|
+
blocks: [{ id: 'root', type: 'stack', props: {} }],
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const response = await app.request('/v1/screens/demo.home', {
|
|
83
|
+
headers: { Authorization: 'Bearer tok-123' },
|
|
84
|
+
});
|
|
85
|
+
expect(response.status).toBe(200);
|
|
86
|
+
expect(seenToken).toBe('tok-123');
|
|
87
|
+
const body = await response.json();
|
|
88
|
+
expect(body.screen_id).toBe('demo.home');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('testing helpers', () => {
|
|
93
|
+
it('createScreenBuilderContext defaults empty maps', () => {
|
|
94
|
+
expect(createScreenBuilderContext({ authToken: 'a' })).toEqual({
|
|
95
|
+
authToken: 'a',
|
|
96
|
+
query: {},
|
|
97
|
+
routeParams: {},
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('createMockSupabaseClient resolves table and rpc results', async () => {
|
|
102
|
+
const client = createMockSupabaseClient({
|
|
103
|
+
fromResults: {
|
|
104
|
+
profiles: { data: { id: '1' }, error: null },
|
|
105
|
+
},
|
|
106
|
+
rpcResults: {
|
|
107
|
+
ping: { data: true, error: null },
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const profile = await client.from('profiles').select('*').eq('id', '1').maybeSingle();
|
|
112
|
+
expect(profile.data).toEqual({ id: '1' });
|
|
113
|
+
|
|
114
|
+
const rpc = await client.rpc('ping');
|
|
115
|
+
expect(rpc.data).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -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';
|