@proulr/bff-core 0.1.1 → 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 +44 -7
- package/src/native-ad-slot-insertion.test.ts +42 -0
- package/src/native-ad-slot-insertion.ts +90 -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
|
@@ -5,11 +5,17 @@ import { cors } from 'hono/cors';
|
|
|
5
5
|
import type { SduiScreenBuilder, SduiScreenBuilderContext } from '@proulr/sdui-contract';
|
|
6
6
|
import { createBffMetrics, isBffMetricsEnabled } from './bff-metrics';
|
|
7
7
|
|
|
8
|
+
export type SduiActionHandler = (
|
|
9
|
+
ctx: SduiScreenBuilderContext,
|
|
10
|
+
body: Record<string, unknown>,
|
|
11
|
+
) => Promise<Record<string, unknown>>;
|
|
12
|
+
|
|
8
13
|
export type CreateSduiBffOptions = {
|
|
9
14
|
name: string;
|
|
10
15
|
port: number;
|
|
11
16
|
bffId: string;
|
|
12
17
|
screens: Record<string, SduiScreenBuilder>;
|
|
18
|
+
actions?: Record<string, SduiActionHandler>;
|
|
13
19
|
};
|
|
14
20
|
|
|
15
21
|
function buildContext(
|
|
@@ -107,13 +113,34 @@ export function createSduiBff(options: CreateSduiBffOptions) {
|
|
|
107
113
|
|
|
108
114
|
app.post('/v1/actions/:actionId', async (c) => {
|
|
109
115
|
const actionId = c.req.param('actionId');
|
|
110
|
-
const body = await c.req.json().catch(() => ({}))
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
const body = (await c.req.json().catch(() => ({}))) as Record<string, unknown>;
|
|
117
|
+
const handler = options.actions?.[actionId];
|
|
118
|
+
|
|
119
|
+
if (!handler) {
|
|
120
|
+
return c.json({
|
|
121
|
+
ok: false,
|
|
122
|
+
action_id: actionId,
|
|
123
|
+
error: 'action_not_found',
|
|
124
|
+
message: 'Action endpoint stub — register handler in BFF actions map.',
|
|
125
|
+
}, 404);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const authToken = readAuthToken(c.req.header('Authorization'));
|
|
130
|
+
const result = await handler(buildContext({}, {}, authToken), body);
|
|
131
|
+
return c.json({ ok: true, action_id: actionId, ...result });
|
|
132
|
+
} catch (error) {
|
|
133
|
+
console.warn(`[${options.bffId}] action ${actionId} failed`, error);
|
|
134
|
+
return c.json(
|
|
135
|
+
{
|
|
136
|
+
ok: false,
|
|
137
|
+
action_id: actionId,
|
|
138
|
+
error: 'action_failed',
|
|
139
|
+
message: error instanceof Error ? error.message : 'action_failed',
|
|
140
|
+
},
|
|
141
|
+
500,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
117
144
|
});
|
|
118
145
|
|
|
119
146
|
return {
|
|
@@ -128,3 +155,13 @@ export function createSduiBff(options: CreateSduiBffOptions) {
|
|
|
128
155
|
|
|
129
156
|
export * from '@proulr/sdui-contract';
|
|
130
157
|
export { createBffSupabaseClient, isBffSupabaseConfigured } from './bff-supabase-client';
|
|
158
|
+
export {
|
|
159
|
+
buildNativeAdInsertionSeed,
|
|
160
|
+
insertRandomNativeAdSlots,
|
|
161
|
+
type InsertRandomNativeAdSlotsOptions,
|
|
162
|
+
type NativeAdSlotWire,
|
|
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,90 @@
|
|
|
1
|
+
export type NativeAdSlotWire = {
|
|
2
|
+
kind: 'ad';
|
|
3
|
+
id: string;
|
|
4
|
+
placement: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type InsertRandomNativeAdSlotsOptions = {
|
|
8
|
+
placement: string;
|
|
9
|
+
seed: string;
|
|
10
|
+
minContentBeforeFirstAd?: number;
|
|
11
|
+
minGap?: number;
|
|
12
|
+
maxGap?: number;
|
|
13
|
+
maxAds?: number;
|
|
14
|
+
idPrefix?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function hashSeed(seed: string): number {
|
|
18
|
+
let hash = 2166136261;
|
|
19
|
+
for (let index = 0; index < seed.length; index += 1) {
|
|
20
|
+
hash ^= seed.charCodeAt(index);
|
|
21
|
+
hash = Math.imul(hash, 16777619);
|
|
22
|
+
}
|
|
23
|
+
return hash >>> 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createSeededRandom(seed: number): () => number {
|
|
27
|
+
let state = seed || 1;
|
|
28
|
+
return () => {
|
|
29
|
+
state = (state + 0x6d2b79f5) | 0;
|
|
30
|
+
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
31
|
+
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
|
|
32
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function randomInt(random: () => number, min: number, max: number): number {
|
|
37
|
+
if (max <= min) return min;
|
|
38
|
+
return min + Math.floor(random() * (max - min + 1));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildNativeAdInsertionSeed(parts: string[]): string {
|
|
42
|
+
return parts.filter(Boolean).join(':');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function insertRandomNativeAdSlots<T>(
|
|
46
|
+
items: T[],
|
|
47
|
+
options: InsertRandomNativeAdSlotsOptions,
|
|
48
|
+
): Array<T | NativeAdSlotWire> {
|
|
49
|
+
const minContentBeforeFirstAd = options.minContentBeforeFirstAd ?? 2;
|
|
50
|
+
const minGap = options.minGap ?? 4;
|
|
51
|
+
const maxGap = options.maxGap ?? 8;
|
|
52
|
+
const maxAds = options.maxAds ?? Math.max(1, Math.floor(items.length / 12));
|
|
53
|
+
const idPrefix = options.idPrefix ?? options.placement;
|
|
54
|
+
|
|
55
|
+
if (!items.length || items.length < minContentBeforeFirstAd + 1) {
|
|
56
|
+
return [...items];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const random = createSeededRandom(hashSeed(options.seed));
|
|
60
|
+
const result: Array<T | NativeAdSlotWire> = [];
|
|
61
|
+
let sourceIndex = 0;
|
|
62
|
+
let adsInserted = 0;
|
|
63
|
+
let contentSinceLastAd = 0;
|
|
64
|
+
let nextAdAfter = randomInt(random, minGap, maxGap);
|
|
65
|
+
|
|
66
|
+
while (sourceIndex < items.length) {
|
|
67
|
+
const shouldInsertAd =
|
|
68
|
+
adsInserted < maxAds &&
|
|
69
|
+
contentSinceLastAd >= nextAdAfter &&
|
|
70
|
+
sourceIndex >= minContentBeforeFirstAd;
|
|
71
|
+
|
|
72
|
+
if (shouldInsertAd) {
|
|
73
|
+
result.push({
|
|
74
|
+
kind: 'ad',
|
|
75
|
+
id: `${idPrefix}-ad-${adsInserted}`,
|
|
76
|
+
placement: options.placement,
|
|
77
|
+
});
|
|
78
|
+
adsInserted += 1;
|
|
79
|
+
contentSinceLastAd = 0;
|
|
80
|
+
nextAdAfter = randomInt(random, minGap, maxGap);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
result.push(items[sourceIndex]);
|
|
85
|
+
sourceIndex += 1;
|
|
86
|
+
contentSinceLastAd += 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
@@ -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';
|