Skip to content

Commit 8b2d97f

Browse files
committed
feat: update test scripts and add integration tests for CRUD operations in app-react-crud
1 parent 35e10fd commit 8b2d97f

5 files changed

Lines changed: 347 additions & 30 deletions

File tree

examples/app-react-crud/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"dev": "pnpm msw:init && vite",
1010
"build": "pnpm msw:init && tsc && vite build",
1111
"typecheck": "tsc --noEmit",
12-
"test": "objectstack test:run",
12+
"test": "vitest run",
13+
"test:bdd": "objectstack test:run",
1314
"preview": "vite preview"
1415
},
1516
"dependencies": {
@@ -32,11 +33,13 @@
3233
"@types/react-dom": "^18.3.1",
3334
"@vitejs/plugin-react": "^5.1.3",
3435
"autoprefixer": "^10.4.24",
36+
"happy-dom": "^20.5.0",
3537
"msw": "^2.0.0",
3638
"postcss": "^8.5.6",
3739
"tailwindcss": "^4.1.18",
3840
"typescript": "^5.0.0",
39-
"vite": "^7.3.1"
41+
"vite": "^7.3.1",
42+
"vitest": "^4.0.18"
4043
},
4144
"msw": {
4245
"workerDirectory": [
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
2+
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
3+
import { setupServer } from 'msw/node';
4+
import { ObjectKernel, DriverPlugin, AppPlugin } from '@objectstack/runtime';
5+
import { ObjectQLPlugin, SchemaRegistry } from '@objectstack/objectql';
6+
import { InMemoryDriver } from '@objectstack/driver-memory';
7+
import { MSWPlugin } from '@objectstack/plugin-msw';
8+
import { ObjectStackClient } from '@objectstack/client';
9+
import todoConfig from '@example/app-todo/objectstack.config';
10+
11+
// Define the API Base URL for testing
12+
const API_BASE = 'http://localhost:3000';
13+
14+
describe('App React CRUD Integration Tests', () => {
15+
let kernel: ObjectKernel;
16+
let server: any;
17+
let client: ObjectStackClient;
18+
19+
beforeAll(async () => {
20+
// 1. Initialize Kernel with In-Memory Driver and Todo App
21+
kernel = new ObjectKernel();
22+
const driver = new InMemoryDriver();
23+
24+
await kernel.use(new ObjectQLPlugin());
25+
await kernel.use(new DriverPlugin(driver, 'memory'));
26+
await kernel.use(new AppPlugin(todoConfig));
27+
28+
// 2. Initialize MSW Plugin to generate handlers
29+
const mswPlugin = new MSWPlugin({
30+
// We disable browser mode since we are in Node
31+
enableBrowser: false,
32+
baseUrl: '/api/v1',
33+
logRequests: true
34+
});
35+
await kernel.use(mswPlugin);
36+
37+
// --- BROKER SHIM START ---
38+
// HttpDispatcher requires a broker to function. We inject a simple shim.
39+
(kernel as any).broker = {
40+
call: async (action: string, params: any, opts: any) => {
41+
const parts = action.split('.');
42+
const service = parts[0];
43+
const method = parts[1];
44+
45+
// Get Engines
46+
const ql = kernel.context?.getService<any>('objectql');
47+
48+
if (service === 'data') {
49+
if (method === 'create') {
50+
const res = await ql.insert(params.object, params.data);
51+
return { ...params.data, ...res };
52+
}
53+
if (method === 'get') {
54+
// Workaround: Manual find because driver filtering seems flaky in test env for 'id'
55+
const all = await ql.find(params.object);
56+
if (all && Array.isArray(all)) {
57+
const match = all.find((i: any) => i.id === params.id || i._id === params.id);
58+
return match || null;
59+
}
60+
return null;
61+
}
62+
if (method === 'update') {
63+
// ql.update(obj, data, options) - inject ID into data
64+
return ql.update(params.object, { ...params.data, id: params.id });
65+
}
66+
if (method === 'delete') {
67+
// ql.delete(obj, options) - pass ID as filter
68+
return ql.delete(params.object, { filter: params.id });
69+
}
70+
if (method === 'find' || method === 'query') {
71+
// Manual filtering workaround for test environment
72+
let all = await ql.find(params.object);
73+
if (!all) all = [];
74+
75+
const filters = params.filters;
76+
if (filters && typeof filters === 'object' && !Array.isArray(filters)) {
77+
const keys = Object.keys(filters);
78+
if (keys.length > 0) {
79+
all = all.filter((item: any) => {
80+
return keys.every(k => item[k] === filters[k]);
81+
});
82+
}
83+
}
84+
85+
// HttpDispatcher expects { data, count } for query/list
86+
return { data: all, count: all.length };
87+
}
88+
}
89+
90+
if (service === 'metadata') {
91+
if (method === 'objects') {
92+
// Try engine first
93+
let objs = ql && ql.getObjects ? ql.getObjects() : [];
94+
// Fallback to Registry if engine returns empty (likely delayed sync)
95+
if (objs.length === 0) {
96+
objs = SchemaRegistry.getAllObjects();
97+
}
98+
99+
console.log('DEBUG: metadata.objects returned count:', objs.length, 'names:', objs.map((o: any) => o.name));
100+
return objs;
101+
}
102+
if (method === 'getObject') {
103+
// Try Registry first for speed/correctness
104+
return SchemaRegistry.getObject(params.objectName) || ql.getObject(params.objectName);
105+
}
106+
}
107+
108+
console.warn(`[BrokerShim] Action not implemented: ${action}`);
109+
return null;
110+
}
111+
};
112+
// --- BROKER SHIM END ---
113+
114+
await kernel.bootstrap();
115+
116+
// --- PROTOCOL SERVICE MOCK ---
117+
// Overwrite protocol service because the default one might be empty/broken in this env
118+
if (kernel.services instanceof Map) {
119+
kernel.services.set('protocol', {
120+
getUiView: async ({ object, type }: any) => {
121+
return {
122+
type: type || 'list',
123+
name: 'default',
124+
object: object,
125+
title: object,
126+
body: []
127+
};
128+
}
129+
});
130+
}
131+
132+
133+
// 3. Set up MSW Node Server with handlers from the plugin
134+
const handlers = mswPlugin.getHandlers();
135+
console.log('DEBUG: MSW Handlers registered:', handlers.map(h => h.info.header));
136+
server = setupServer(...handlers);
137+
server.listen({ onUnhandledRequest: 'error' });
138+
139+
// 4. Initialize Client
140+
client = new ObjectStackClient({
141+
baseUrl: 'http://localhost:3000',
142+
// In Node/Vitest we often need to polyfill fetch or pass it if not global
143+
// Vitest (via happy-dom/jsdom) usually polyfills it, or Node 18+ has it.
144+
});
145+
});
146+
147+
afterEach(() => {
148+
server.resetHandlers();
149+
});
150+
151+
afterAll(async () => {
152+
server.close();
153+
});
154+
155+
it('should connect and discover capabilities', async () => {
156+
const discovery = await client.connect();
157+
158+
expect(discovery).toBeDefined();
159+
// Discovery uses fixed name in HttpDispatcher
160+
expect(discovery.name).toBe('ObjectOS');
161+
// Discovery returns full paths including prefix if used. Since we used '/api/v1', it is /api/v1/meta
162+
expect(discovery.routes.metadata).toBe('/api/v1/meta');
163+
});
164+
165+
it('should list objects metadata', async () => {
166+
// Find metadata for 'todo_task' using 'meta.getItems'
167+
const response = await client.meta.getItems('objects');
168+
expect(response).toBeDefined();
169+
// response structure depends on protocol (GetMetaItemsResponse) -> { success: true, data: [...] } or just [...]
170+
// Client returns res.json(). HttpDispatcher returns { success: true, data: [], meta: ... }
171+
// Wait, Client wraps calls? No, client.meta.getItems() returns `res.json()`.
172+
// HttpDispatcher returns { body: { success: true, data: ..., meta: ... } }
173+
const objects = response.data;
174+
expect(objects).toBeDefined();
175+
176+
const taskObject = objects.find((o: any) => o.name === 'todo_task');
177+
expect(taskObject).toBeDefined();
178+
expect(taskObject?.label).toBe('Todo Task');
179+
});
180+
181+
it('should check if ui protocol getUiView is available', async () => {
182+
// Use client.meta.getView
183+
// This might fail if protocol service doesn't support getUiView in this test env
184+
// Note: ObjectQLPlugin might not include UI logic by default if it's in a separate package or requires explicit enable
185+
186+
const response = await client.meta.getView('todo_task', 'list');
187+
// Response from HttpDispatcher: { success: true, data: ViewDef }
188+
expect(response.success).toBe(true);
189+
expect(response.data).toBeDefined();
190+
expect(response.data.type).toBe('list');
191+
});
192+
193+
it('should perform CRUD on todo_task', async () => {
194+
// 1. Create
195+
const newTask = {
196+
title: 'Test Task from Integration',
197+
status: 'todo',
198+
due_date: new Date().toISOString()
199+
};
200+
201+
// Use client.data.create
202+
const response = await client.data.create('todo_task', newTask);
203+
// HttpDispatcher wrap: { success: true, data: CreatedRecord }
204+
const created = response.data;
205+
206+
expect(created).toBeDefined();
207+
expect(created.id).toBeDefined();
208+
expect(created.title).toBe(newTask.title);
209+
210+
const taskId = created.id;
211+
212+
// 2. Read (Get)
213+
const getRes = await client.data.get('todo_task', taskId);
214+
const fetched = getRes.data;
215+
expect(fetched).toBeDefined();
216+
expect(fetched.id).toBe(taskId);
217+
218+
// 3. Update
219+
const updateRes = await client.data.update('todo_task', taskId, { status: 'completed' });
220+
const updated = updateRes.data;
221+
expect(updated.status).toBe('completed');
222+
223+
// 4. Read (List) with filter
224+
// Use client.data.find
225+
const findRes = await client.data.find('todo_task', {
226+
filters: { status: 'completed' }
227+
});
228+
const list = findRes.data;
229+
expect(list.length).toBeGreaterThan(0);
230+
expect(list[0].id).toBe(taskId);
231+
232+
// 5. Delete
233+
await client.data.delete('todo_task', taskId);
234+
235+
// Verify Deletion
236+
try {
237+
await client.data.get('todo_task', taskId);
238+
// Should fail
239+
} catch (e: any) {
240+
// Client throws on error (non-ok status)
241+
expect(e.httpStatus).toBeDefined();
242+
}
243+
});
244+
245+
});

packages/plugins/plugin-msw/src/msw-plugin.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,8 +294,8 @@ export class MSWPlugin implements Plugin {
294294
};
295295

296296
this.handlers.push(
297-
http.all(`${baseUrl}/*`, catchAll),
298-
http.all(`${baseUrl}`, catchAll) // Handle root if needed
297+
http.all(`*${baseUrl}/*`, catchAll),
298+
http.all(`*${baseUrl}`, catchAll) // Handle root if needed
299299
);
300300

301301
ctx.logger.info('MSW handlers set up using HttpDispatcher', { baseUrl });

packages/runtime/src/http-dispatcher.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,7 @@ export class HttpDispatcher {
5757
const hasAnalytics = !!services[CoreServiceName.enum.analytics];
5858
const hasHub = !!services[CoreServiceName.enum.hub];
5959

60-
return {
61-
name: 'ObjectOS',
62-
version: '1.0.0',
63-
environment: getEnv('NODE_ENV', 'development'),
64-
routes: {
60+
const routes = {
6561
data: `${prefix}/data`,
6662
metadata: `${prefix}/meta`,
6763
auth: `${prefix}/auth`,
@@ -70,7 +66,15 @@ export class HttpDispatcher {
7066
storage: hasFiles ? `${prefix}/storage` : undefined,
7167
analytics: hasAnalytics ? `${prefix}/analytics` : undefined,
7268
hub: hasHub ? `${prefix}/hub` : undefined,
73-
},
69+
automation: `${prefix}/automation`,
70+
};
71+
72+
return {
73+
name: 'ObjectOS',
74+
version: '1.0.0',
75+
environment: getEnv('NODE_ENV', 'development'),
76+
routes,
77+
endpoints: routes, // Alias for backward compatibility with some clients
7478
features: {
7579
graphql: hasGraphQL,
7680
search: hasSearch,
@@ -483,12 +487,15 @@ export class HttpDispatcher {
483487
* Handles UI requests
484488
* path: sub-path after /ui/
485489
*/
486-
async handleUi(path: string, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
490+
async handleUi(path: string, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
487491
const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
488492

489-
// GET /ui/view/:object/:type
490-
if (parts[0] === 'view' && parts.length === 3) {
491-
const [_, objectName, type] = parts;
493+
// GET /ui/view/:object (with optional type param)
494+
if (parts[0] === 'view' && parts[1]) {
495+
const objectName = parts[1];
496+
// Support both path param /view/obj/list AND query param /view/obj?type=list
497+
const type = parts[2] || query?.type || 'list';
498+
492499
const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
493500

494501
if (protocol && typeof protocol.getUiView === 'function') {
@@ -589,7 +596,7 @@ export class HttpDispatcher {
589596
}
590597

591598
if (cleanPath.startsWith('/ui')) {
592-
return this.handleUi(cleanPath.substring(3), context);
599+
return this.handleUi(cleanPath.substring(3), query, context);
593600
}
594601

595602
if (cleanPath.startsWith('/automation')) {

0 commit comments

Comments
 (0)