@objectql/server 1.7.2 → 1.8.0
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/CHANGELOG.md +21 -0
- package/LICENSE +1 -1
- package/README.md +93 -0
- package/dist/adapters/graphql.d.ts +17 -0
- package/dist/adapters/graphql.js +460 -0
- package/dist/adapters/graphql.js.map +1 -0
- package/dist/adapters/rest.d.ts +7 -5
- package/dist/adapters/rest.js +52 -15
- package/dist/adapters/rest.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/metadata.js +7 -7
- package/dist/metadata.js.map +1 -1
- package/dist/server.d.ts +4 -0
- package/dist/server.js +85 -10
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +15 -8
- package/package.json +6 -3
- package/src/adapters/graphql.ts +523 -0
- package/src/adapters/rest.ts +49 -15
- package/src/index.ts +2 -0
- package/src/metadata.ts +7 -7
- package/src/server.ts +100 -11
- package/src/types.ts +24 -8
- package/test/graphql.test.ts +439 -0
- package/test/integration-example.ts +247 -0
- package/test/node.test.ts +4 -2
- package/test/rest-advanced.test.ts +455 -0
- package/test/rest.test.ts +283 -7
- package/tsconfig.tsbuildinfo +1 -1
package/src/server.ts
CHANGED
|
@@ -58,17 +58,27 @@ export class ObjectQLServer {
|
|
|
58
58
|
switch (req.op) {
|
|
59
59
|
case 'find':
|
|
60
60
|
result = await repo.find(req.args);
|
|
61
|
-
|
|
61
|
+
// For find operations, return items array with pagination metadata
|
|
62
|
+
return this.buildListResponse(result, req.args, repo);
|
|
62
63
|
case 'findOne':
|
|
63
64
|
// Support both string ID and query object
|
|
64
65
|
result = await repo.findOne(req.args);
|
|
65
|
-
|
|
66
|
+
if (result) {
|
|
67
|
+
return { ...result, '@type': req.object };
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
66
70
|
case 'create':
|
|
67
71
|
result = await repo.create(req.args);
|
|
68
|
-
|
|
72
|
+
if (result) {
|
|
73
|
+
return { ...result, '@type': req.object };
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
69
76
|
case 'update':
|
|
70
77
|
result = await repo.update(req.args.id, req.args.data);
|
|
71
|
-
|
|
78
|
+
if (result) {
|
|
79
|
+
return { ...result, '@type': req.object };
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
72
82
|
case 'delete':
|
|
73
83
|
result = await repo.delete(req.args.id);
|
|
74
84
|
if (!result) {
|
|
@@ -77,12 +87,15 @@ export class ObjectQLServer {
|
|
|
77
87
|
`Record with id '${req.args.id}' not found for delete`
|
|
78
88
|
);
|
|
79
89
|
}
|
|
80
|
-
// Return standardized delete response
|
|
81
|
-
|
|
82
|
-
|
|
90
|
+
// Return standardized delete response with object type
|
|
91
|
+
return {
|
|
92
|
+
id: req.args.id,
|
|
93
|
+
deleted: true,
|
|
94
|
+
'@type': req.object
|
|
95
|
+
};
|
|
83
96
|
case 'count':
|
|
84
97
|
result = await repo.count(req.args);
|
|
85
|
-
|
|
98
|
+
return { count: result, '@type': req.object };
|
|
86
99
|
case 'action':
|
|
87
100
|
// Map generic args to ActionContext
|
|
88
101
|
result = await app.executeAction(req.object, req.args.action, {
|
|
@@ -90,7 +103,52 @@ export class ObjectQLServer {
|
|
|
90
103
|
id: req.args.id,
|
|
91
104
|
input: req.args.input || req.args.params // Support both for convenience
|
|
92
105
|
});
|
|
93
|
-
|
|
106
|
+
if (result && typeof result === 'object') {
|
|
107
|
+
return { ...result, '@type': req.object };
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
case 'createMany':
|
|
111
|
+
// Bulk create operation
|
|
112
|
+
if (!Array.isArray(req.args)) {
|
|
113
|
+
return this.errorResponse(
|
|
114
|
+
ErrorCode.INVALID_REQUEST,
|
|
115
|
+
'createMany expects args to be an array of records'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
result = await repo.createMany(req.args);
|
|
119
|
+
return {
|
|
120
|
+
items: result,
|
|
121
|
+
count: Array.isArray(result) ? result.length : 0,
|
|
122
|
+
'@type': req.object
|
|
123
|
+
};
|
|
124
|
+
case 'updateMany':
|
|
125
|
+
// Bulk update operation
|
|
126
|
+
// args should be { filters, data }
|
|
127
|
+
if (!req.args || typeof req.args !== 'object' || !req.args.data) {
|
|
128
|
+
return this.errorResponse(
|
|
129
|
+
ErrorCode.INVALID_REQUEST,
|
|
130
|
+
'updateMany expects args to be an object with { filters, data }'
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
result = await repo.updateMany(req.args.filters || {}, req.args.data);
|
|
134
|
+
return {
|
|
135
|
+
count: result,
|
|
136
|
+
'@type': req.object
|
|
137
|
+
};
|
|
138
|
+
case 'deleteMany':
|
|
139
|
+
// Bulk delete operation
|
|
140
|
+
// args should be { filters }
|
|
141
|
+
if (!req.args || typeof req.args !== 'object') {
|
|
142
|
+
return this.errorResponse(
|
|
143
|
+
ErrorCode.INVALID_REQUEST,
|
|
144
|
+
'deleteMany expects args to be an object with { filters }'
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
result = await repo.deleteMany(req.args.filters || {});
|
|
148
|
+
return {
|
|
149
|
+
count: result,
|
|
150
|
+
'@type': req.object
|
|
151
|
+
};
|
|
94
152
|
default:
|
|
95
153
|
return this.errorResponse(
|
|
96
154
|
ErrorCode.INVALID_REQUEST,
|
|
@@ -98,13 +156,44 @@ export class ObjectQLServer {
|
|
|
98
156
|
);
|
|
99
157
|
}
|
|
100
158
|
|
|
101
|
-
return { data: result };
|
|
102
|
-
|
|
103
159
|
} catch (e: any) {
|
|
104
160
|
return this.handleError(e);
|
|
105
161
|
}
|
|
106
162
|
}
|
|
107
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Build a standardized list response with pagination metadata
|
|
166
|
+
*/
|
|
167
|
+
private async buildListResponse(items: any[], args: any, repo: any): Promise<ObjectQLResponse> {
|
|
168
|
+
const response: ObjectQLResponse = {
|
|
169
|
+
items
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Calculate pagination metadata if limit/skip are present
|
|
173
|
+
if (args && (args.limit || args.skip)) {
|
|
174
|
+
const skip = args.skip || 0;
|
|
175
|
+
const limit = args.limit || items.length;
|
|
176
|
+
|
|
177
|
+
// Get total count - use the same arguments as the query to ensure consistency
|
|
178
|
+
const total = await repo.count(args || {});
|
|
179
|
+
|
|
180
|
+
const size = limit;
|
|
181
|
+
const page = limit > 0 ? Math.floor(skip / limit) + 1 : 1;
|
|
182
|
+
const pages = limit > 0 ? Math.ceil(total / limit) : 1;
|
|
183
|
+
const has_next = skip + items.length < total;
|
|
184
|
+
|
|
185
|
+
response.meta = {
|
|
186
|
+
total,
|
|
187
|
+
page,
|
|
188
|
+
size,
|
|
189
|
+
pages,
|
|
190
|
+
has_next
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return response;
|
|
195
|
+
}
|
|
196
|
+
|
|
108
197
|
/**
|
|
109
198
|
* Handle errors and convert them to appropriate error responses
|
|
110
199
|
*/
|
package/src/types.ts
CHANGED
|
@@ -37,7 +37,7 @@ export interface ObjectQLRequest {
|
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
// The actual operation
|
|
40
|
-
op: 'find' | 'findOne' | 'create' | 'update' | 'delete' | 'count' | 'action';
|
|
40
|
+
op: 'find' | 'findOne' | 'create' | 'update' | 'delete' | 'count' | 'action' | 'createMany' | 'updateMany' | 'deleteMany';
|
|
41
41
|
object: string;
|
|
42
42
|
|
|
43
43
|
// Arguments
|
|
@@ -60,19 +60,35 @@ export interface ErrorDetails {
|
|
|
60
60
|
[key: string]: unknown;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Pagination metadata
|
|
65
|
+
*/
|
|
66
|
+
export interface PaginationMeta {
|
|
67
|
+
total: number; // Total number of records
|
|
68
|
+
page?: number; // Current page number (1-indexed, e.g. page 1 corresponds to skip=0)
|
|
69
|
+
size?: number; // Number of items per page
|
|
70
|
+
pages?: number; // Total number of pages
|
|
71
|
+
has_next?: boolean; // Whether there is a next page
|
|
72
|
+
}
|
|
73
|
+
|
|
63
74
|
/**
|
|
64
75
|
* ObjectQL API response
|
|
65
76
|
*/
|
|
66
77
|
export interface ObjectQLResponse {
|
|
67
|
-
|
|
78
|
+
// For list operations (find)
|
|
79
|
+
items?: any[];
|
|
80
|
+
|
|
81
|
+
// Pagination metadata (for list operations)
|
|
82
|
+
meta?: PaginationMeta;
|
|
83
|
+
|
|
84
|
+
// Error information
|
|
68
85
|
error?: {
|
|
69
86
|
code: ErrorCode | string;
|
|
70
87
|
message: string;
|
|
71
|
-
details?: ErrorDetails;
|
|
72
|
-
};
|
|
73
|
-
meta?: {
|
|
74
|
-
total?: number;
|
|
75
|
-
page?: number;
|
|
76
|
-
per_page?: number;
|
|
88
|
+
details?: ErrorDetails | any; // Allow flexible details structure
|
|
77
89
|
};
|
|
90
|
+
|
|
91
|
+
// For single item operations, the response is the object itself with '@type' field
|
|
92
|
+
// This allows any additional fields from the actual data object
|
|
93
|
+
[key: string]: any;
|
|
78
94
|
}
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import request from 'supertest';
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import { ObjectQL } from '@objectql/core';
|
|
4
|
+
import { createGraphQLHandler } from '../src/adapters/graphql';
|
|
5
|
+
import { Driver } from '@objectql/types';
|
|
6
|
+
|
|
7
|
+
// Simple Mock Driver
|
|
8
|
+
class MockDriver implements Driver {
|
|
9
|
+
private initialData: Record<string, any[]> = {
|
|
10
|
+
user: [
|
|
11
|
+
{ _id: '1', name: 'Alice', email: 'alice@example.com', age: 30 },
|
|
12
|
+
{ _id: '2', name: 'Bob', email: 'bob@example.com', age: 25 }
|
|
13
|
+
],
|
|
14
|
+
task: [
|
|
15
|
+
{ _id: 'task1', title: 'Task 1', status: 'open', priority: 'high' },
|
|
16
|
+
{ _id: 'task2', title: 'Task 2', status: 'closed', priority: 'low' }
|
|
17
|
+
]
|
|
18
|
+
};
|
|
19
|
+
private data: Record<string, any[]>;
|
|
20
|
+
private nextId = 3;
|
|
21
|
+
|
|
22
|
+
constructor() {
|
|
23
|
+
// Create a deep copy of initialData
|
|
24
|
+
this.data = JSON.parse(JSON.stringify(this.initialData));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
reset() {
|
|
28
|
+
// Reset data to initial state
|
|
29
|
+
this.data = JSON.parse(JSON.stringify(this.initialData));
|
|
30
|
+
this.nextId = 3;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async init() {}
|
|
34
|
+
|
|
35
|
+
async find(objectName: string, query: any) {
|
|
36
|
+
let items = this.data[objectName] || [];
|
|
37
|
+
|
|
38
|
+
// Apply skip and limit if provided
|
|
39
|
+
if (query) {
|
|
40
|
+
if (query.skip) {
|
|
41
|
+
items = items.slice(query.skip);
|
|
42
|
+
}
|
|
43
|
+
if (query.limit) {
|
|
44
|
+
items = items.slice(0, query.limit);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return items;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async findOne(objectName: string, id: string | number, query?: any, options?: any) {
|
|
52
|
+
const items = this.data[objectName] || [];
|
|
53
|
+
if (id !== undefined && id !== null) {
|
|
54
|
+
const found = items.find(item => item._id === String(id));
|
|
55
|
+
return found || null;
|
|
56
|
+
}
|
|
57
|
+
return items[0] || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async create(objectName: string, data: any) {
|
|
61
|
+
const newItem = { _id: String(this.nextId++), ...data };
|
|
62
|
+
if (!this.data[objectName]) {
|
|
63
|
+
this.data[objectName] = [];
|
|
64
|
+
}
|
|
65
|
+
this.data[objectName].push(newItem);
|
|
66
|
+
return newItem;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Note: update() returns the updated object to match the behavior of the SQL driver,
|
|
70
|
+
// which returns { id, ...data }. This is different from the affected row count pattern.
|
|
71
|
+
async update(objectName: string, id: string, data: any) {
|
|
72
|
+
const items = this.data[objectName] || [];
|
|
73
|
+
const index = items.findIndex(item => item._id === id);
|
|
74
|
+
if (index >= 0) {
|
|
75
|
+
this.data[objectName][index] = { ...items[index], ...data };
|
|
76
|
+
return this.data[objectName][index];
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async delete(objectName: string, id: string) {
|
|
82
|
+
const items = this.data[objectName] || [];
|
|
83
|
+
const index = items.findIndex(item => item._id === id);
|
|
84
|
+
if (index >= 0) {
|
|
85
|
+
this.data[objectName].splice(index, 1);
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async count(objectName: string, query: any) {
|
|
92
|
+
return (this.data[objectName] || []).length;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async execute(sql: string) {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe('GraphQL API Adapter', () => {
|
|
99
|
+
let app: ObjectQL;
|
|
100
|
+
let server: any;
|
|
101
|
+
let handler: any;
|
|
102
|
+
let mockDriver: MockDriver;
|
|
103
|
+
|
|
104
|
+
beforeAll(async () => {
|
|
105
|
+
mockDriver = new MockDriver();
|
|
106
|
+
app = new ObjectQL({
|
|
107
|
+
datasources: {
|
|
108
|
+
default: mockDriver
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Register user object schema
|
|
113
|
+
app.metadata.register('object', {
|
|
114
|
+
type: 'object',
|
|
115
|
+
id: 'user',
|
|
116
|
+
content: {
|
|
117
|
+
name: 'user',
|
|
118
|
+
label: 'User',
|
|
119
|
+
fields: {
|
|
120
|
+
name: { type: 'text', label: 'Name' },
|
|
121
|
+
email: { type: 'email', label: 'Email' },
|
|
122
|
+
age: { type: 'number', label: 'Age' }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Register task object schema
|
|
128
|
+
app.metadata.register('object', {
|
|
129
|
+
type: 'object',
|
|
130
|
+
id: 'task',
|
|
131
|
+
content: {
|
|
132
|
+
name: 'task',
|
|
133
|
+
label: 'Task',
|
|
134
|
+
fields: {
|
|
135
|
+
title: { type: 'text', label: 'Title', required: true },
|
|
136
|
+
status: { type: 'select', label: 'Status', options: ['open', 'closed'] },
|
|
137
|
+
priority: { type: 'select', label: 'Priority', options: ['low', 'medium', 'high'] }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Create handler and server once for all tests
|
|
143
|
+
handler = createGraphQLHandler(app);
|
|
144
|
+
server = createServer(handler);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
beforeEach(() => {
|
|
148
|
+
// Reset mock data before each test
|
|
149
|
+
mockDriver.reset();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('Queries', () => {
|
|
153
|
+
it('should query a single user by ID', async () => {
|
|
154
|
+
const query = `
|
|
155
|
+
query {
|
|
156
|
+
user(id: "1") {
|
|
157
|
+
id
|
|
158
|
+
name
|
|
159
|
+
email
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
`;
|
|
163
|
+
|
|
164
|
+
const response = await request(server)
|
|
165
|
+
.post('/api/graphql')
|
|
166
|
+
.send({ query })
|
|
167
|
+
.set('Accept', 'application/json');
|
|
168
|
+
|
|
169
|
+
expect(response.status).toBe(200);
|
|
170
|
+
expect(response.body.data).toBeDefined();
|
|
171
|
+
expect(response.body.data.user).toEqual({
|
|
172
|
+
id: '1',
|
|
173
|
+
name: 'Alice',
|
|
174
|
+
email: 'alice@example.com'
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should query list of users', async () => {
|
|
179
|
+
const query = `
|
|
180
|
+
query {
|
|
181
|
+
userList {
|
|
182
|
+
id
|
|
183
|
+
name
|
|
184
|
+
email
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
`;
|
|
188
|
+
|
|
189
|
+
const response = await request(server)
|
|
190
|
+
.post('/api/graphql')
|
|
191
|
+
.send({ query })
|
|
192
|
+
.set('Accept', 'application/json');
|
|
193
|
+
|
|
194
|
+
expect(response.status).toBe(200);
|
|
195
|
+
expect(response.body.data).toBeDefined();
|
|
196
|
+
expect(response.body.data.userList).toHaveLength(2);
|
|
197
|
+
expect(response.body.data.userList[0].name).toBe('Alice');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should support pagination with limit and skip', async () => {
|
|
201
|
+
const query = `
|
|
202
|
+
query {
|
|
203
|
+
userList(limit: 1, skip: 1) {
|
|
204
|
+
id
|
|
205
|
+
name
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
`;
|
|
209
|
+
|
|
210
|
+
const response = await request(server)
|
|
211
|
+
.post('/api/graphql')
|
|
212
|
+
.send({ query })
|
|
213
|
+
.set('Accept', 'application/json');
|
|
214
|
+
|
|
215
|
+
expect(response.status).toBe(200);
|
|
216
|
+
expect(response.body.data.userList).toHaveLength(1);
|
|
217
|
+
expect(response.body.data.userList[0].name).toBe('Bob');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should support field selection', async () => {
|
|
221
|
+
const query = `
|
|
222
|
+
query {
|
|
223
|
+
userList(fields: ["name"]) {
|
|
224
|
+
id
|
|
225
|
+
name
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
`;
|
|
229
|
+
|
|
230
|
+
const response = await request(server)
|
|
231
|
+
.post('/api/graphql')
|
|
232
|
+
.send({ query })
|
|
233
|
+
.set('Accept', 'application/json');
|
|
234
|
+
|
|
235
|
+
expect(response.status).toBe(200);
|
|
236
|
+
expect(response.body.data.userList).toBeDefined();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should query tasks', async () => {
|
|
240
|
+
const query = `
|
|
241
|
+
query {
|
|
242
|
+
taskList {
|
|
243
|
+
id
|
|
244
|
+
title
|
|
245
|
+
status
|
|
246
|
+
priority
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
`;
|
|
250
|
+
|
|
251
|
+
const response = await request(server)
|
|
252
|
+
.post('/api/graphql')
|
|
253
|
+
.send({ query })
|
|
254
|
+
.set('Accept', 'application/json');
|
|
255
|
+
|
|
256
|
+
expect(response.status).toBe(200);
|
|
257
|
+
expect(response.body.data.taskList).toHaveLength(2);
|
|
258
|
+
expect(response.body.data.taskList[0].title).toBe('Task 1');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should support GET requests with query parameter', async () => {
|
|
262
|
+
const query = encodeURIComponent('{ user(id: "1") { id name } }');
|
|
263
|
+
|
|
264
|
+
const response = await request(server)
|
|
265
|
+
.get(`/api/graphql?query=${query}`)
|
|
266
|
+
.set('Accept', 'application/json');
|
|
267
|
+
|
|
268
|
+
expect(response.status).toBe(200);
|
|
269
|
+
expect(response.body.data.user.name).toBe('Alice');
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe('Mutations', () => {
|
|
274
|
+
it('should create a new user', async () => {
|
|
275
|
+
const mutation = `
|
|
276
|
+
mutation {
|
|
277
|
+
createUser(input: { name: "Charlie", email: "charlie@example.com", age: 35 }) {
|
|
278
|
+
id
|
|
279
|
+
name
|
|
280
|
+
email
|
|
281
|
+
age
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
`;
|
|
285
|
+
|
|
286
|
+
const response = await request(server)
|
|
287
|
+
.post('/api/graphql')
|
|
288
|
+
.send({ query: mutation })
|
|
289
|
+
.set('Accept', 'application/json');
|
|
290
|
+
|
|
291
|
+
expect(response.status).toBe(200);
|
|
292
|
+
expect(response.body.data).toBeDefined();
|
|
293
|
+
expect(response.body.data.createUser.name).toBe('Charlie');
|
|
294
|
+
expect(response.body.data.createUser.email).toBe('charlie@example.com');
|
|
295
|
+
expect(response.body.data.createUser.id).toBeDefined();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should update an existing user', async () => {
|
|
299
|
+
const mutation = `
|
|
300
|
+
mutation {
|
|
301
|
+
updateUser(id: "1", input: { name: "Alice Updated" }) {
|
|
302
|
+
id
|
|
303
|
+
name
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
`;
|
|
307
|
+
|
|
308
|
+
const response = await request(server)
|
|
309
|
+
.post('/api/graphql')
|
|
310
|
+
.send({ query: mutation })
|
|
311
|
+
.set('Accept', 'application/json');
|
|
312
|
+
|
|
313
|
+
expect(response.status).toBe(200);
|
|
314
|
+
expect(response.body.data).toBeDefined();
|
|
315
|
+
expect(response.body.data.updateUser.name).toBe('Alice Updated');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('should delete a user', async () => {
|
|
319
|
+
const mutation = `
|
|
320
|
+
mutation {
|
|
321
|
+
deleteUser(id: "2") {
|
|
322
|
+
id
|
|
323
|
+
deleted
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
`;
|
|
327
|
+
|
|
328
|
+
const response = await request(server)
|
|
329
|
+
.post('/api/graphql')
|
|
330
|
+
.send({ query: mutation })
|
|
331
|
+
.set('Accept', 'application/json');
|
|
332
|
+
|
|
333
|
+
expect(response.status).toBe(200);
|
|
334
|
+
expect(response.body.data).toBeDefined();
|
|
335
|
+
expect(response.body.data.deleteUser.id).toBe('2');
|
|
336
|
+
expect(response.body.data.deleteUser.deleted).toBe(true);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('should create a task', async () => {
|
|
340
|
+
const mutation = `
|
|
341
|
+
mutation {
|
|
342
|
+
createTask(input: { title: "New Task", status: "open", priority: "medium" }) {
|
|
343
|
+
id
|
|
344
|
+
title
|
|
345
|
+
status
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
`;
|
|
349
|
+
|
|
350
|
+
const response = await request(server)
|
|
351
|
+
.post('/api/graphql')
|
|
352
|
+
.send({ query: mutation })
|
|
353
|
+
.set('Accept', 'application/json');
|
|
354
|
+
|
|
355
|
+
expect(response.status).toBe(200);
|
|
356
|
+
expect(response.body.data.createTask.title).toBe('New Task');
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe('Error Handling', () => {
|
|
361
|
+
it('should return error for invalid query', async () => {
|
|
362
|
+
const query = `query { invalid }`;
|
|
363
|
+
|
|
364
|
+
const response = await request(server)
|
|
365
|
+
.post('/api/graphql')
|
|
366
|
+
.send({ query })
|
|
367
|
+
.set('Accept', 'application/json');
|
|
368
|
+
|
|
369
|
+
expect(response.status).toBe(200);
|
|
370
|
+
expect(response.body.errors).toBeDefined();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('should return error when query is missing', async () => {
|
|
374
|
+
const response = await request(server)
|
|
375
|
+
.post('/api/graphql')
|
|
376
|
+
.send({})
|
|
377
|
+
.set('Accept', 'application/json');
|
|
378
|
+
|
|
379
|
+
expect(response.status).toBe(400);
|
|
380
|
+
expect(response.body.errors).toBeDefined();
|
|
381
|
+
expect(response.body.errors[0].message).toContain('query');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('should reject non-POST/GET methods', async () => {
|
|
385
|
+
const response = await request(server)
|
|
386
|
+
.put('/api/graphql')
|
|
387
|
+
.send({ query: '{ userList { id } }' })
|
|
388
|
+
.set('Accept', 'application/json');
|
|
389
|
+
|
|
390
|
+
expect(response.status).toBe(405);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe('Variables', () => {
|
|
395
|
+
it('should support GraphQL variables', async () => {
|
|
396
|
+
const query = `
|
|
397
|
+
query GetUser($userId: String!) {
|
|
398
|
+
user(id: $userId) {
|
|
399
|
+
id
|
|
400
|
+
name
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
`;
|
|
404
|
+
|
|
405
|
+
const response = await request(server)
|
|
406
|
+
.post('/api/graphql')
|
|
407
|
+
.send({
|
|
408
|
+
query,
|
|
409
|
+
variables: { userId: '1' }
|
|
410
|
+
})
|
|
411
|
+
.set('Accept', 'application/json');
|
|
412
|
+
|
|
413
|
+
expect(response.status).toBe(200);
|
|
414
|
+
expect(response.body.data.user.name).toBe('Alice');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('should support variables in GET requests', async () => {
|
|
418
|
+
const query = encodeURIComponent('query GetUser($id: String!) { user(id: $id) { name } }');
|
|
419
|
+
const variables = encodeURIComponent(JSON.stringify({ id: '1' }));
|
|
420
|
+
|
|
421
|
+
const response = await request(server)
|
|
422
|
+
.get(`/api/graphql?query=${query}&variables=${variables}`)
|
|
423
|
+
.set('Accept', 'application/json');
|
|
424
|
+
|
|
425
|
+
expect(response.status).toBe(200);
|
|
426
|
+
expect(response.body.data.user.name).toBe('Alice');
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe('CORS', () => {
|
|
431
|
+
it('should handle OPTIONS preflight request', async () => {
|
|
432
|
+
const response = await request(server)
|
|
433
|
+
.options('/api/graphql');
|
|
434
|
+
|
|
435
|
+
expect(response.status).toBe(200);
|
|
436
|
+
expect(response.headers['access-control-allow-methods']).toContain('POST');
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
});
|