google-drive-mock 1.0.9 → 1.0.11

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/src/routes/v3.ts CHANGED
@@ -21,55 +21,67 @@ export const createV3Router = () => {
21
21
 
22
22
  if (q) {
23
23
  // Enhanced query parser for Mock
24
- const parts = q.split(' and ').map(p => p.trim());
25
-
24
+ const orParts = q.split(' or ');
26
25
  files = files.filter(file => {
27
- return parts.every(part => {
28
- // name = '...'
29
- if (part.startsWith("name = '")) {
30
- const name = part.match(/name = '(.*)'/)?.[1];
31
- return file.name === name;
32
- }
33
- // name contains '...'
34
- if (part.startsWith("name contains '")) {
35
- const token = part.match(/name contains '(.*)'/)?.[1];
36
- return token && file.name.includes(token);
37
- }
38
- // 'ID' in parents
39
- if (part.includes(" in parents")) {
40
- const parentId = part.match(/'(.*)' in parents/)?.[1];
41
- return parentId && file.parents?.includes(parentId);
42
- }
43
- // trashed = ...
44
- if (part === "trashed = false") {
45
- return file.trashed !== true;
46
- }
47
- if (part === "trashed = true") {
48
- return file.trashed === true;
49
- }
50
- // mimeType = '...'
51
- if (part.startsWith("mimeType = '")) {
52
- const mime = part.match(/mimeType = '(.*)'/)?.[1];
53
- return file.mimeType === mime;
54
- }
55
- // mimeType != '...'
56
- if (part.startsWith("mimeType != '")) {
57
- const mime = part.match(/mimeType != '(.*)'/)?.[1];
58
- return file.mimeType !== mime;
59
- }
60
- // modifiedTime > '...'
61
- if (part.startsWith("modifiedTime > '")) {
62
- const timeStr = part.match(/modifiedTime > '(.*)'/)?.[1];
63
- return timeStr && new Date(file.modifiedTime) > new Date(timeStr);
64
- }
65
- // modifiedTime < '...'
66
- if (part.startsWith("modifiedTime < '")) {
67
- const timeStr = part.match(/modifiedTime < '(.*)'/)?.[1];
68
- return timeStr && new Date(file.modifiedTime) < new Date(timeStr);
69
- }
26
+ return orParts.some(orPart => {
27
+ const andParts = orPart.split(' and ').map(p => p.trim());
28
+ return andParts.every(part => {
29
+ // name = '...'
30
+ if (part.startsWith("name = '")) {
31
+ const name = part.match(/name = '(.*)'/)?.[1];
32
+ // Handle escaped quotes if simple match fails, but simple regex here might be enough for now
33
+ // The user does name.replace("'", "\\'") but the regex (.*) is greedy and might consume escaped quotes correctly-ish
34
+ // but we are stripping the outer quotes.
35
+ // If user sends: name = 'foo\'bar', regex captures: foo\'bar
36
+ // We need to unescape? The user sends escaped string for the Query Parser.
37
+ // Drive API expects the string inside quotes to be the value.
38
+ // If `name = 'a\'b'`, the value is `a'b`.
39
+ // JSON.parse(`"${name}"`) might decode it if we treat it as JSON string? Use simple replace for now.
40
+ const finalName = name ? name.replace(/\\'/g, "'") : name;
41
+ return file.name === finalName;
42
+ }
43
+ // name contains '...'
44
+ if (part.startsWith("name contains '")) {
45
+ const token = part.match(/name contains '(.*)'/)?.[1];
46
+ const finalToken = token ? token.replace(/\\'/g, "'") : token;
47
+ return finalToken && file.name.includes(finalToken);
48
+ }
49
+ // 'ID' in parents
50
+ if (part.includes(" in parents")) {
51
+ const parentId = part.match(/'(.*)' in parents/)?.[1];
52
+ return parentId && file.parents?.includes(parentId);
53
+ }
54
+ // trashed = ...
55
+ if (part === "trashed = false") {
56
+ return file.trashed !== true;
57
+ }
58
+ if (part === "trashed = true") {
59
+ return file.trashed === true;
60
+ }
61
+ // mimeType = '...'
62
+ if (part.startsWith("mimeType = '")) {
63
+ const mime = part.match(/mimeType = '(.*)'/)?.[1];
64
+ return file.mimeType === mime;
65
+ }
66
+ // mimeType != '...'
67
+ if (part.startsWith("mimeType != '")) {
68
+ const mime = part.match(/mimeType != '(.*)'/)?.[1];
69
+ return file.mimeType !== mime;
70
+ }
71
+ // modifiedTime > '...'
72
+ if (part.startsWith("modifiedTime > '")) {
73
+ const timeStr = part.match(/modifiedTime > '(.*)'/)?.[1];
74
+ return timeStr && new Date(file.modifiedTime) > new Date(timeStr);
75
+ }
76
+ // modifiedTime < '...'
77
+ if (part.startsWith("modifiedTime < '")) {
78
+ const timeStr = part.match(/modifiedTime < '(.*)'/)?.[1];
79
+ return timeStr && new Date(file.modifiedTime) < new Date(timeStr);
80
+ }
70
81
 
71
- // Ignore unknown filters for now
72
- return true;
82
+ // Ignore unknown filters for now
83
+ return true;
84
+ });
73
85
  });
74
86
  });
75
87
  }
@@ -143,8 +155,25 @@ export const createV3Router = () => {
143
155
  // Upload Files Route
144
156
  app.post('/upload/drive/v3/files', (req: Request, res: Response) => {
145
157
  const uploadType = req.query.uploadType;
146
- if (uploadType !== 'multipart') {
147
- res.status(400).json({ error: { code: 400, message: "Only uploadType=multipart is supported in this mock route" } });
158
+ if (uploadType !== 'multipart' && uploadType !== 'media') {
159
+ res.status(400).json({ error: { code: 400, message: "Only uploadType=multipart or uploadType=media is supported in this mock route" } });
160
+ return;
161
+ }
162
+
163
+ if (uploadType === 'media') {
164
+ const rawBody = req.body;
165
+ // Handle edge case where express.json() parses empty body as {}
166
+ if (req.headers['content-length'] === '0' && JSON.stringify(rawBody) === '{}') {
167
+ // Empty body
168
+ }
169
+
170
+ const newFile = driveStore.createFile({
171
+ name: "Untitled",
172
+ mimeType: req.headers['content-type'] || "application/octet-stream",
173
+ parents: [],
174
+ content: typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody) // Handle body if parsed
175
+ });
176
+ res.status(200).json(newFile);
148
177
  return;
149
178
  }
150
179
 
@@ -214,23 +243,6 @@ export const createV3Router = () => {
214
243
  content = contentPart.body;
215
244
  }
216
245
 
217
- const existing = driveStore.listFiles().find(f => {
218
- if (f.name !== metadata.name) return false;
219
- // Filter trashed?
220
- if (f.trashed) return false;
221
-
222
- const newParents = metadata.parents || [];
223
- const existingParents = f.parents || [];
224
-
225
- if (newParents.length === 0 && existingParents.length === 0) return true;
226
- return newParents.some((p: string) => existingParents.includes(p));
227
- });
228
-
229
- if (existing) {
230
- res.status(409).json({ error: { code: 409, message: "Conflict: File with same name already exists" } });
231
- return;
232
- }
233
-
234
246
  const newFile = driveStore.createFile({
235
247
  ...metadata,
236
248
  content: content
@@ -266,8 +278,81 @@ export const createV3Router = () => {
266
278
  return;
267
279
  }
268
280
 
269
- // Add multipart support if needed, but media is primary for now
270
- res.status(400).json({ error: { code: 400, message: "Only uploadType=media is currently supported for V3 PATCH upload" } });
281
+ const contentTypeHeader = req.headers['content-type'];
282
+ const contentType = Array.isArray(contentTypeHeader) ? contentTypeHeader[0] : contentTypeHeader;
283
+
284
+ // Check for multipart
285
+ if (contentType && contentType.includes('multipart/related')) {
286
+ const boundaryMatch = contentType.match(/boundary=(.+)/);
287
+ if (!boundaryMatch) {
288
+ res.status(400).json({ error: { code: 400, message: "Multipart boundary missing" } });
289
+ return;
290
+ }
291
+ let boundary = boundaryMatch[1];
292
+ if (boundary.startsWith('"') && boundary.endsWith('"')) {
293
+ boundary = boundary.substring(1, boundary.length - 1);
294
+ }
295
+
296
+ const rawBody = req.body;
297
+ if (typeof rawBody !== 'string') {
298
+ res.status(400).json({ error: { code: 400, message: "Body parsing failed" } });
299
+ return;
300
+ }
301
+
302
+ const parts = rawBody.split(`--${boundary}`);
303
+ const validParts = parts.filter(p => p.trim() !== '' && p.trim() !== '--');
304
+
305
+ if (validParts.length < 2) {
306
+ res.status(400).json({ error: { code: 400, message: "Invalid multipart body: expected at least metadata and content" } });
307
+ return;
308
+ }
309
+
310
+ const parsePart = (rawPart: string) => {
311
+ const splitIndex = rawPart.indexOf('\r\n\r\n');
312
+ if (splitIndex === -1) return null;
313
+ const headers = rawPart.substring(0, splitIndex).trim();
314
+ const body = rawPart.substring(splitIndex + 4);
315
+ return {
316
+ headers,
317
+ body: body.replace(/\r\n$/, '') // Remove trailing CRLF from part body
318
+ };
319
+ };
320
+
321
+ const metadataPart = parsePart(validParts[0]);
322
+ const contentPart = parsePart(validParts[1]);
323
+
324
+ if (!metadataPart || !contentPart) {
325
+ res.status(400).json({ error: { code: 400, message: "Failed to parse parts" } });
326
+ return;
327
+ }
328
+
329
+ let metadata;
330
+ try {
331
+ metadata = JSON.parse(metadataPart.body);
332
+ } catch {
333
+ res.status(400).json({ error: { code: 400, message: "Invalid JSON in metadata part" } });
334
+ return;
335
+ }
336
+
337
+ let content;
338
+ try {
339
+ content = JSON.parse(contentPart.body);
340
+ } catch {
341
+ content = contentPart.body;
342
+ }
343
+
344
+ // Perform update
345
+ const updatedFile = driveStore.updateFile(fileId, {
346
+ ...metadata,
347
+ content: content,
348
+ modifiedTime: new Date().toISOString()
349
+ });
350
+
351
+ res.status(200).json(updatedFile);
352
+ return;
353
+ }
354
+
355
+ res.status(400).json({ error: { code: 400, message: "Only uploadType=media or multipart/related is supported for V3 PATCH upload" } });
271
356
  });
272
357
 
273
358
  // Files: Create (Standard)
package/src/store.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import * as crypto from 'crypto';
2
+
1
3
  export interface DriveFile {
2
4
  id: string;
3
5
  name: string;
@@ -9,6 +11,8 @@ export interface DriveFile {
9
11
  trashed: boolean;
10
12
  createdTime: string;
11
13
  modifiedTime: string;
14
+ size: string;
15
+ md5Checksum: string;
12
16
  [key: string]: unknown;
13
17
  }
14
18
 
@@ -46,22 +50,45 @@ export class DriveStore {
46
50
  this.changes = [];
47
51
  }
48
52
 
53
+ private calculateStats(content: unknown): { size: string, md5Checksum: string } {
54
+ let buffer: Buffer;
55
+ if (typeof content === 'string') {
56
+ buffer = Buffer.from(content);
57
+ } else if (content === undefined || content === null) {
58
+ buffer = Buffer.from('');
59
+ } else {
60
+ buffer = Buffer.from(JSON.stringify(content));
61
+ }
62
+
63
+ return {
64
+ size: String(buffer.length),
65
+ md5Checksum: crypto.createHash('md5').update(buffer).digest('hex')
66
+ };
67
+ }
68
+
49
69
  createFile(file: Partial<DriveFile> & { name: string }): DriveFile {
50
70
  if (!file.name) {
51
71
  throw new Error("File name is required");
52
72
  }
53
73
  const id = file.id || Math.random().toString(36).substring(7);
54
74
  const now = new Date().toISOString();
75
+
76
+ const stats = this.calculateStats(file.content);
77
+
55
78
  const newFile: DriveFile = {
56
79
  kind: "drive#file",
57
80
  mimeType: "application/octet-stream",
58
81
  trashed: false,
59
82
  createdTime: now,
60
83
  modifiedTime: now,
84
+
61
85
  ...file,
62
86
  id,
63
87
  version: 1, // Initialize version
64
88
  etag: "1", // Initialize etag
89
+ // Ensure calculated stats override provided ones
90
+ size: stats.size,
91
+ md5Checksum: stats.md5Checksum
65
92
  };
66
93
 
67
94
  this.files.set(id, newFile);
@@ -73,11 +100,18 @@ export class DriveStore {
73
100
  const file = this.files.get(id);
74
101
  if (!file) return null;
75
102
 
103
+ // If content is being updated, recalculate stats
104
+ let statsUpdates = {};
105
+ if (updates.content !== undefined) {
106
+ statsUpdates = this.calculateStats(updates.content);
107
+ }
108
+
76
109
  // Merge updates and increment version
77
110
  const newVersion = file.version + 1;
78
111
  const updatedFile = {
79
112
  ...file,
80
113
  ...updates,
114
+ ...statsUpdates,
81
115
  version: newVersion,
82
116
  etag: String(newVersion),
83
117
  modifiedTime: new Date().toISOString()
@@ -0,0 +1,206 @@
1
+
2
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
3
+ import { getTestConfig, TestConfig } from './config';
4
+
5
+ describe('Batch and Complex Query Operations', () => {
6
+ let config: TestConfig;
7
+ let createdFileIds: string[] = [];
8
+
9
+ beforeAll(async () => {
10
+ config = await getTestConfig();
11
+ });
12
+
13
+ afterAll(async () => {
14
+ // Cleanup if needed
15
+ if (config) config.stop();
16
+ });
17
+
18
+ it('should perform bulk insert using batch API', async () => {
19
+ const docs = [
20
+ { id: 'bulk1', content: '{"foo":1}' },
21
+ { id: 'bulk2', content: '{"bar":2}' }
22
+ ];
23
+ const boundary = "batch_" + Math.random().toString(16).slice(2);
24
+
25
+ // Ensure we have a valid folder ID. Using root or a test folder from config.
26
+ const targetFolderId = config.testFolderId;
27
+
28
+ const parts = docs.map((doc, i) => {
29
+ const id = doc.id;
30
+ const body = JSON.stringify({
31
+ name: id + '.json',
32
+ mimeType: 'application/json',
33
+ parents: [targetFolderId],
34
+ });
35
+
36
+ return (
37
+ `--${boundary}\r\n` +
38
+ `Content-Type: application/http\r\n` +
39
+ `Content-ID: <item-${i}>\r\n\r\n` +
40
+ `POST /drive/v3/files HTTP/1.1\r\n` +
41
+ `Content-Type: application/json; charset=UTF-8\r\n\r\n` +
42
+ `${body}\r\n`
43
+ );
44
+ });
45
+
46
+ const batchBody = parts.join("") + `--${boundary}--`;
47
+
48
+ const url = config.baseUrl + "/batch/drive/v3";
49
+ console.log('Sending batch insert request to:', url);
50
+
51
+ const res = await fetch(url, {
52
+ method: "POST",
53
+ headers: {
54
+ Authorization: `Bearer ${config.token}`,
55
+ "Content-Type": `multipart/mixed; boundary=${boundary}`,
56
+ },
57
+ body: batchBody,
58
+ });
59
+
60
+ expect(res.status).toBe(200);
61
+
62
+ const contentType = res.headers.get('content-type');
63
+ expect(contentType).toContain('multipart/mixed');
64
+
65
+ const text = await res.text();
66
+ console.log('Batch Insert Response:', text);
67
+
68
+ // Verify operations succeeded
69
+ expect(text).toContain('HTTP/1.1 200 OK');
70
+
71
+ // Extract IDs for later use in update tests
72
+ // This is a bit hacky parsing but sufficient for test
73
+ // Responses are JSON inside multipart
74
+ // We can list files to get IDs reliably
75
+ const listRes = await fetch(config.baseUrl + `/drive/v3/files?q='${targetFolderId}'+in+parents`, {
76
+ headers: { Authorization: `Bearer ${config.token}` }
77
+ });
78
+ const listData = await listRes.json();
79
+ const files = listData.files.filter((f: { name: string; id: string }) => f.name === 'bulk1.json' || f.name === 'bulk2.json');
80
+ expect(files.length).toBe(2);
81
+ createdFileIds = files.map((f: { id: string }) => f.id);
82
+ });
83
+
84
+ it('should perform bulk find using complex query', async () => {
85
+ // Ensure the files exist from previous test
86
+ expect(createdFileIds.length).toBe(2);
87
+ const docIds = ['bulk1', 'bulk2'];
88
+
89
+ const fileNames = docIds.map(id => id + '.json');
90
+ let q = fileNames
91
+ .map(name => `name = '${name.replace("'", "\\'")}'`)
92
+ .join(' or ');
93
+ q += ' and trashed = false';
94
+ q += ' and \'' + config.testFolderId + '\' in parents';
95
+
96
+ console.log('Bulk Find Query:', q);
97
+
98
+ const params = new URLSearchParams({
99
+ q,
100
+ fields: "nextPageToken, files(id,name,mimeType,parents,modifiedTime,size)",
101
+ includeItemsFromAllDrives: "true",
102
+ supportsAllDrives: "true",
103
+ });
104
+ const url = config.baseUrl + '/drive/v3/files?' + params.toString();
105
+ const res = await fetch(url, {
106
+ method: "GET",
107
+ headers: {
108
+ Authorization: `Bearer ${config.token}`,
109
+ },
110
+ });
111
+
112
+ expect(res.status).toBe(200);
113
+ const data = await res.json();
114
+
115
+ // Should find both files
116
+ expect(data.files).toBeDefined();
117
+ // Depending on query parsing logic, it might find one or both or none if logic is broken
118
+ // The expectation is that this query works "like" finding specific files in a folder
119
+ const foundNames = data.files.map((f: { name: string }) => f.name);
120
+ expect(foundNames).toContain('bulk1.json');
121
+ expect(foundNames).toContain('bulk2.json');
122
+ expect(data.files.length).toBeGreaterThanOrEqual(2);
123
+ });
124
+
125
+ it('should perform bulk update using batch API', async () => {
126
+ expect(createdFileIds.length).toBe(2);
127
+
128
+ interface DocUpdate {
129
+ id: string;
130
+ newName: string;
131
+ }
132
+ const docs: DocUpdate[] = [
133
+ { id: 'bulk1', newName: 'bulk1_updated' },
134
+ { id: 'bulk2', newName: 'bulk2_updated' }
135
+ ];
136
+
137
+ // Map doc ID to file ID (assuming order or searching)
138
+ // For simplicity, we'll fetch IDs again or use stored ones knowing names match
139
+ // Let's assume createdFileIds corresponds to 'bulk1.json' and 'bulk2.json' somehow
140
+ // But better to use exact mapping.
141
+
142
+ // Re-fetch to be sure of mapping
143
+ const listRes = await fetch(config.baseUrl + `/drive/v3/files?q='${config.testFolderId}'+in+parents`, {
144
+ headers: { Authorization: `Bearer ${config.token}` }
145
+ });
146
+ const listData = await listRes.json();
147
+ const fileIdByDocId: Record<string, string> = {};
148
+ for (const f of listData.files) {
149
+ if (f.name === 'bulk1.json') fileIdByDocId['bulk1'] = f.id;
150
+ if (f.name === 'bulk2.json') fileIdByDocId['bulk2'] = f.id;
151
+ }
152
+
153
+ const boundary = "batch_" + Math.random().toString(16).slice(2);
154
+
155
+ const parts = docs.map((doc, i) => {
156
+ const id = doc.id;
157
+ const fileId = fileIdByDocId[id];
158
+ if (!fileId) throw new Error(`File ID not found for ${id}`);
159
+
160
+ const body = JSON.stringify({
161
+ name: doc.newName + '.json',
162
+ mimeType: "application/json",
163
+ // parents: [config.testFolderId], // Optional in update usually
164
+ });
165
+
166
+ return (
167
+ `--${boundary}\r\n` +
168
+ `Content-Type: application/http\r\n` +
169
+ `Content-ID: <item-${i}>\r\n\r\n` +
170
+ `PATCH /drive/v3/files/${encodeURIComponent(fileId)}?supportsAllDrives=true&fields=id,name,mimeType,parents HTTP/1.1\r\n` +
171
+ `Content-Type: application/json; charset=UTF-8\r\n\r\n` +
172
+ `${body}\r\n`
173
+ );
174
+ });
175
+
176
+ const batchBody = parts.join("") + `--${boundary}--`;
177
+
178
+ const url = config.baseUrl + "/batch/drive/v3";
179
+ console.log('Sending batch update request to:', url);
180
+
181
+ const res = await fetch(url, {
182
+ method: "POST",
183
+ headers: {
184
+ Authorization: `Bearer ${config.token}`,
185
+ "Content-Type": `multipart/mixed; boundary=${boundary}`,
186
+ },
187
+ body: batchBody,
188
+ });
189
+
190
+ expect(res.status).toBe(200);
191
+ const text = await res.text();
192
+ console.log('Batch Update Response:', text);
193
+
194
+ expect(text).toContain('HTTP/1.1 200 OK');
195
+
196
+ // Verify updates
197
+ const verifyRes = await fetch(config.baseUrl + `/drive/v3/files?q='${config.testFolderId}'+in+parents`, {
198
+ headers: { Authorization: `Bearer ${config.token}` }
199
+ });
200
+ const verifyData = await verifyRes.json();
201
+ const names = verifyData.files.map((f: { name: string }) => f.name);
202
+ expect(names).toContain('bulk1_updated.json');
203
+ expect(names).toContain('bulk2_updated.json');
204
+ expect(names).not.toContain('bulk1.json');
205
+ });
206
+ });