google-drive-mock 1.0.2 → 1.0.4

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/AGENTS.md CHANGED
@@ -7,3 +7,8 @@ After making changes to the codebase or to the tests, always run the following c
7
7
  3. `npm test`
8
8
  4. `npm test:browser`
9
9
  5. `npm test:real`
10
+
11
+ ## Remember
12
+
13
+ Whenever the `npm test:real` fails, fix the tests to match the "real" backend.
14
+ Ensure that the behavior of the mock is exactly equal to the "real" backend.
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ const batch_1 = require("./batch");
20
20
  const createApp = (config = {}) => {
21
21
  const app = (0, express_1.default)();
22
22
  app.use((0, cors_1.default)());
23
+ app.set('etag', false); // Disable default ETag generation to match Real API behavior
23
24
  app.use((req, res, next) => __awaiter(void 0, void 0, void 0, function* () {
24
25
  if (config.serverLagBefore && config.serverLagBefore > 0) {
25
26
  yield new Promise(resolve => setTimeout(resolve, config.serverLagBefore));
@@ -277,7 +278,20 @@ const createApp = (config = {}) => {
277
278
  }
278
279
  // Create File
279
280
  // Ensure name uniqueness check if needed (reusing logic from normal create)
280
- const existing = store_1.driveStore.listFiles().find(f => f.name === metadata.name);
281
+ const existing = store_1.driveStore.listFiles().find(f => {
282
+ if (f.name !== metadata.name)
283
+ return false;
284
+ // Filter trashed?
285
+ if (f.trashed)
286
+ return false;
287
+ const newParents = metadata.parents || [];
288
+ const existingParents = f.parents || [];
289
+ // If both new and existing have NO parents, they are both in root -> Conflict
290
+ if (newParents.length === 0 && existingParents.length === 0)
291
+ return true;
292
+ // Check intersection of parents
293
+ return newParents.some((p) => existingParents.includes(p));
294
+ });
281
295
  if (existing) {
282
296
  res.status(409).json({ error: { code: 409, message: "Conflict: File with same name already exists" } });
283
297
  return;
@@ -293,7 +307,17 @@ const createApp = (config = {}) => {
293
307
  return;
294
308
  }
295
309
  // Enforce Unique Name Constraint (Mock Behavior customization)
296
- const existing = store_1.driveStore.listFiles().find(f => f.name === body.name);
310
+ const existing = store_1.driveStore.listFiles().find(f => {
311
+ if (f.name !== body.name)
312
+ return false;
313
+ if (f.trashed)
314
+ return false;
315
+ const newParents = body.parents || [];
316
+ const existingParents = f.parents || [];
317
+ if (newParents.length === 0 && existingParents.length === 0)
318
+ return true;
319
+ return newParents.some((p) => existingParents.includes(p));
320
+ });
297
321
  if (existing) {
298
322
  res.status(409).json({ error: { code: 409, message: "Conflict: File with same name already exists" } });
299
323
  return;
@@ -313,12 +337,16 @@ const createApp = (config = {}) => {
313
337
  res.status(404).json({ error: { code: 404, message: "File not found" } });
314
338
  return;
315
339
  }
316
- const etag = `"${file.version}"`;
317
- res.setHeader('ETag', etag);
340
+ // Mock does not return ETag header because Real API (v3) does not return it by default/in this context.
341
+ // res.setHeader('ETag', etag);
342
+ // Real API also ignores If-None-Match if ETag is not supported?
343
+ // match behavior: do nothing.
344
+ /*
318
345
  if (req.headers['if-none-match'] === etag) {
319
346
  res.status(304).end();
320
347
  return;
321
348
  }
349
+ */
322
350
  res.json(file);
323
351
  });
324
352
  // Files: Update
@@ -337,6 +365,8 @@ const createApp = (config = {}) => {
337
365
  // Note: Real Google Drive API V3 was observed to allow overwrites (status 200)
338
366
  // on PATCH even with mismatching If-Match headers (likely due to ETag generation nuances).
339
367
  // Relaxing Mock to match Real API behavior (Last Write Wins).
368
+ // Real API V3 observed behavior: Ignores If-Match (Last Write Wins).
369
+ // Mock matches this.
340
370
  /*
341
371
  const existingFile = driveStore.getFile(fileId);
342
372
  if (existingFile) {
@@ -362,7 +392,9 @@ const createApp = (config = {}) => {
362
392
  return;
363
393
  }
364
394
  // Check for Precondition (If-Match)
365
- const existingFile = store_1.driveStore.getFile(fileId);
395
+ // Real API behavior: Ignores If-Match (returns 204 even on mismatch)
396
+ /*
397
+ const existingFile = driveStore.getFile(fileId);
366
398
  if (existingFile) {
367
399
  const ifMatch = req.headers['if-match'];
368
400
  if (ifMatch && ifMatch !== '*' && ifMatch !== `"${existingFile.version}"`) {
@@ -370,6 +402,7 @@ const createApp = (config = {}) => {
370
402
  return;
371
403
  }
372
404
  }
405
+ */
373
406
  const deleted = store_1.driveStore.deleteFile(fileId);
374
407
  if (!deleted) {
375
408
  // According to Google API, delete might return 404 if not found, or 204 if successful (or 200).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "google-drive-mock",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Mock-Server that simulates being google-drive. Used for testing.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ interface AppConfig {
11
11
  const createApp = (config: AppConfig = {}) => {
12
12
  const app = express();
13
13
  app.use(cors());
14
+ app.set('etag', false); // Disable default ETag generation to match Real API behavior
14
15
 
15
16
  app.use(async (req, res, next) => {
16
17
  if (config.serverLagBefore && config.serverLagBefore > 0) {
@@ -300,7 +301,21 @@ const createApp = (config: AppConfig = {}) => {
300
301
 
301
302
  // Create File
302
303
  // Ensure name uniqueness check if needed (reusing logic from normal create)
303
- const existing = driveStore.listFiles().find(f => f.name === metadata.name);
304
+ const existing = driveStore.listFiles().find(f => {
305
+ if (f.name !== metadata.name) return false;
306
+ // Filter trashed?
307
+ if (f.trashed) return false;
308
+
309
+ const newParents = metadata.parents || [];
310
+ const existingParents = f.parents || [];
311
+
312
+ // If both new and existing have NO parents, they are both in root -> Conflict
313
+ if (newParents.length === 0 && existingParents.length === 0) return true;
314
+
315
+ // Check intersection of parents
316
+ return newParents.some((p: string) => existingParents.includes(p));
317
+ });
318
+
304
319
  if (existing) {
305
320
  res.status(409).json({ error: { code: 409, message: "Conflict: File with same name already exists" } });
306
321
  return;
@@ -323,7 +338,18 @@ const createApp = (config: AppConfig = {}) => {
323
338
  }
324
339
 
325
340
  // Enforce Unique Name Constraint (Mock Behavior customization)
326
- const existing = driveStore.listFiles().find(f => f.name === body.name);
341
+ const existing = driveStore.listFiles().find(f => {
342
+ if (f.name !== body.name) return false;
343
+ if (f.trashed) return false;
344
+
345
+ const newParents = body.parents || [];
346
+ const existingParents = f.parents || [];
347
+
348
+ if (newParents.length === 0 && existingParents.length === 0) return true;
349
+
350
+ return newParents.some((p: string) => existingParents.includes(p));
351
+ });
352
+
327
353
  if (existing) {
328
354
  res.status(409).json({ error: { code: 409, message: "Conflict: File with same name already exists" } });
329
355
  return;
@@ -353,13 +379,17 @@ const createApp = (config: AppConfig = {}) => {
353
379
  return;
354
380
  }
355
381
 
356
- const etag = `"${file.version}"`;
357
- res.setHeader('ETag', etag);
382
+ // Mock does not return ETag header because Real API (v3) does not return it by default/in this context.
383
+ // res.setHeader('ETag', etag);
358
384
 
385
+ // Real API also ignores If-None-Match if ETag is not supported?
386
+ // match behavior: do nothing.
387
+ /*
359
388
  if (req.headers['if-none-match'] === etag) {
360
389
  res.status(304).end();
361
390
  return;
362
391
  }
392
+ */
363
393
 
364
394
  res.json(file);
365
395
  });
@@ -382,6 +412,8 @@ const createApp = (config: AppConfig = {}) => {
382
412
  // Note: Real Google Drive API V3 was observed to allow overwrites (status 200)
383
413
  // on PATCH even with mismatching If-Match headers (likely due to ETag generation nuances).
384
414
  // Relaxing Mock to match Real API behavior (Last Write Wins).
415
+ // Real API V3 observed behavior: Ignores If-Match (Last Write Wins).
416
+ // Mock matches this.
385
417
  /*
386
418
  const existingFile = driveStore.getFile(fileId);
387
419
  if (existingFile) {
@@ -411,6 +443,8 @@ const createApp = (config: AppConfig = {}) => {
411
443
  return;
412
444
  }
413
445
  // Check for Precondition (If-Match)
446
+ // Real API behavior: Ignores If-Match (returns 204 even on mismatch)
447
+ /*
414
448
  const existingFile = driveStore.getFile(fileId);
415
449
  if (existingFile) {
416
450
  const ifMatch = req.headers['if-match'];
@@ -419,6 +453,7 @@ const createApp = (config: AppConfig = {}) => {
419
453
  return;
420
454
  }
421
455
  }
456
+ */
422
457
 
423
458
  const deleted = driveStore.deleteFile(fileId);
424
459
 
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { getTestConfig, TestConfig } from './config';
3
+ import { Server } from 'http';
4
+
5
+ // Helper to handle both Server (Node) and URL (Browser)
6
+ async function makeRequest(
7
+ target: Server | string,
8
+ method: string,
9
+ path: string,
10
+ headers: Record<string, string>,
11
+ body?: unknown
12
+ ) {
13
+ if (typeof target === 'string') {
14
+ const url = `${target}${path}`;
15
+ const fetchOptions: RequestInit = {
16
+ method: method,
17
+ headers: headers
18
+ };
19
+ if (body) {
20
+ if (typeof body === 'string') {
21
+ fetchOptions.body = body;
22
+ } else {
23
+ fetchOptions.body = JSON.stringify(body);
24
+ if (!headers['Content-Type']) {
25
+ headers['Content-Type'] = 'application/json';
26
+ }
27
+ }
28
+ }
29
+
30
+ const res = await fetch(url, fetchOptions);
31
+
32
+ const resBody = res.headers.get('content-type')?.includes('application/json')
33
+ ? await res.json()
34
+ : await res.text();
35
+
36
+ return { status: res.status, body: resBody, headers: res.headers };
37
+ } else {
38
+ const addr = target.address();
39
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
40
+ const baseUrl = `http://localhost:${port}`;
41
+
42
+ return makeRequest(baseUrl, method, path, headers, body);
43
+ }
44
+ }
45
+
46
+ describe('ETag and If-Match Support', () => {
47
+ let config: TestConfig;
48
+
49
+ beforeAll(async () => {
50
+ config = await getTestConfig();
51
+ });
52
+
53
+ afterAll(() => {
54
+ if (config) config.stop();
55
+ });
56
+
57
+ async function req(method: string, path: string, body?: unknown, customHeaders: Record<string, string> = {}) {
58
+ const headers = {
59
+ 'Authorization': `Bearer ${config.token}`,
60
+ ...customHeaders
61
+ };
62
+ return makeRequest(config.target, method, path, headers, body);
63
+ }
64
+
65
+ it('should NOT return ETag header when getting a file (Parity with Real API)', async () => {
66
+ // Create file
67
+ const createRes = await req('POST', '/drive/v3/files', {
68
+ name: 'ETag Test File',
69
+ mimeType: 'text/plain',
70
+ parents: [config.testFolderId]
71
+ });
72
+ expect(createRes.status).toBe(200);
73
+ const fileId = createRes.body.id;
74
+
75
+ // Get file
76
+ const getRes = await req('GET', `/drive/v3/files/${fileId}?fields=*`);
77
+
78
+ expect(getRes.status).toBe(200);
79
+ // Real API v3 does not return 'etag' header in this context.
80
+ expect(getRes.headers.get('etag')).toBeFalsy();
81
+ });
82
+
83
+ it('should ignore If-Match (return 200) even if valid (Parity with Real API)', async () => {
84
+ // Create file
85
+ const createRes = await req('POST', '/drive/v3/files', {
86
+ name: 'If-Match Success File',
87
+ parents: [config.testFolderId]
88
+ });
89
+ const fileId = createRes.body.id;
90
+
91
+ // Even if we send a "valid" hypothetical etag, it should pass (200)
92
+ // because Real API allows overwrites and seems to ignore If-Match if no ETag is present/supported.
93
+ const updateRes = await req('PATCH', `/drive/v3/files/${fileId}`, {
94
+ name: 'Updated Name'
95
+ }, {
96
+ 'If-Match': '"1"'
97
+ });
98
+
99
+ expect(updateRes.status).toBe(200);
100
+ expect(updateRes.body.name).toBe('Updated Name');
101
+ });
102
+
103
+ it('should ignore If-Match (return 200) if If-Match does not match (Parity with Real API)', async () => {
104
+ // Create file
105
+ const createRes = await req('POST', '/drive/v3/files', {
106
+ name: 'If-Match Fail File',
107
+ parents: [config.testFolderId]
108
+ });
109
+ const fileId = createRes.body.id;
110
+
111
+ // Update with incorrect If-Match
112
+ const updateRes = await req('PATCH', `/drive/v3/files/${fileId}`, {
113
+ name: 'Should Update Anyway'
114
+ }, {
115
+ 'If-Match': '"invalid-tag"'
116
+ });
117
+
118
+ // Real API returns 200 (Last Write Wins)
119
+ expect(updateRes.status).toBe(200);
120
+ expect(updateRes.body.name).toBe('Should Update Anyway');
121
+ });
122
+
123
+ it('should allow PATCH if If-Match is * (Wildcard)', async () => {
124
+ const createRes = await req('POST', '/drive/v3/files', {
125
+ name: 'Wildcard File',
126
+ parents: [config.testFolderId]
127
+ });
128
+ const fileId = createRes.body.id;
129
+
130
+ const updateRes = await req('PATCH', `/drive/v3/files/${fileId}`, {
131
+ name: 'Wildcard Updated'
132
+ }, {
133
+ 'If-Match': '*'
134
+ });
135
+
136
+ expect(updateRes.status).toBe(200);
137
+ expect(updateRes.body.name).toBe('Wildcard Updated');
138
+ });
139
+
140
+ it('should allow DELETE if If-Match matches (Parity)', async () => {
141
+ const createRes = await req('POST', '/drive/v3/files', {
142
+ name: 'Delete Success File',
143
+ parents: [config.testFolderId]
144
+ });
145
+ const fileId = createRes.body.id;
146
+
147
+ const deleteRes = await req('DELETE', `/drive/v3/files/${fileId}`, undefined, {
148
+ 'If-Match': '"1"'
149
+ });
150
+
151
+ expect(deleteRes.status).toBe(204);
152
+ const verifyRes = await req('GET', `/drive/v3/files/${fileId}`);
153
+ expect(verifyRes.status).toBe(404);
154
+ });
155
+
156
+ it('should allow DELETE (204) if If-Match does not match (Parity)', async () => {
157
+ const createRes = await req('POST', '/drive/v3/files', {
158
+ name: 'Delete Fail File',
159
+ parents: [config.testFolderId]
160
+ });
161
+ const fileId = createRes.body.id;
162
+
163
+ const deleteRes = await req('DELETE', `/drive/v3/files/${fileId}`, undefined, {
164
+ 'If-Match': '"wrong-tag"'
165
+ });
166
+
167
+ // Real API returns 204 (Delete succeeded despite If-Match header)
168
+ expect(deleteRes.status).toBe(204);
169
+
170
+ const verifyRes = await req('GET', `/drive/v3/files/${fileId}`);
171
+ expect(verifyRes.status).toBe(404);
172
+ });
173
+ });
@@ -140,26 +140,10 @@ describe('Complex Routines', () => {
140
140
  return false;
141
141
  }
142
142
 
143
- // EXPECT SUCCESS (Overwrite) -> Google Drive doesn't enforced lock on this.
143
+ // Real API behavior (as observed) allows overwrite (Last Write Wins)
144
+ // Mock now aligned to this.
144
145
  expect(failUpdate.status).toBe(200);
145
146
  return false; // Loop continues until we decide to release or successful acquire?
146
- // Wait, if we overwrote it, we broke the lock.
147
- // The test logic was: "Client B fails to write -> Lock works".
148
- // Now: "Client B OVERWRITES -> Lock failed".
149
- // We need to adjust the test goal.
150
- // If we expect overwrite, then Client B *Successfully Acquired* (by stealing)?
151
- // Or we just verify behavior.
152
-
153
- // Let's change the test to:
154
- // Client B tries to acquire.
155
- // If file exists, it overwrites it.
156
- // This is NOT a lock simulation anymore.
157
-
158
- // Actually, let's keep the structure but change expectation.
159
- // If overwrite succeeds, Client B effectively "won" but incorrectly.
160
-
161
- // For the sake of "passing tests against real API", we assert 200.
162
- return false; // Keep waiting? No, if we overwrote, we are done?
163
147
  } else {
164
148
  // Lock released, try to Acquire
165
149
  const acquire = await req('POST', '/drive/v3/files', {
@@ -87,4 +87,86 @@ describe('Multipart Upload Feature', () => {
87
87
  expect(file['content']).toEqual(jsonContent);
88
88
  }
89
89
  });
90
+ it('should allow creating files with the same name in different folders (multipart)', async () => {
91
+ // 1. Create Parent A
92
+ const parentResA = await fetch(`${config.baseUrl}/drive/v3/files`, {
93
+ method: 'POST',
94
+ headers: {
95
+ 'Authorization': `Bearer ${config.token}`,
96
+ 'Content-Type': 'application/json'
97
+ },
98
+ body: JSON.stringify({
99
+ name: 'FolderA_' + Date.now(),
100
+ mimeType: 'application/vnd.google-apps.folder',
101
+ parents: [config.testFolderId]
102
+ })
103
+ });
104
+ const parentIdA = (await parentResA.json()).id;
105
+
106
+ // 2. Create Parent B
107
+ const parentResB = await fetch(`${config.baseUrl}/drive/v3/files`, {
108
+ method: 'POST',
109
+ headers: {
110
+ 'Authorization': `Bearer ${config.token}`,
111
+ 'Content-Type': 'application/json'
112
+ },
113
+ body: JSON.stringify({
114
+ name: 'FolderB_' + Date.now(),
115
+ mimeType: 'application/vnd.google-apps.folder',
116
+ parents: [config.testFolderId]
117
+ })
118
+ });
119
+ const parentIdB = (await parentResB.json()).id;
120
+
121
+ const commonFileName = 'DuplicateName.json';
122
+ const content = { foo: 'bar' };
123
+
124
+ // Helper to do multipart upload
125
+ const uploadFile = async (parentId: string) => {
126
+ const metadata = {
127
+ name: commonFileName,
128
+ parents: [parentId],
129
+ mimeType: 'application/json'
130
+ };
131
+
132
+ const multipartBoundary = '-------UniqueBoundary' + Date.now();
133
+ const delimiter = '\r\n--' + multipartBoundary + '\r\n';
134
+ const closeDelim = '\r\n--' + multipartBoundary + '--';
135
+
136
+ const body = delimiter +
137
+ 'Content-Type: application/json\r\n\r\n' +
138
+ JSON.stringify(metadata) +
139
+ delimiter +
140
+ 'Content-Type: application/json\r\n\r\n' +
141
+ JSON.stringify(content) +
142
+ closeDelim;
143
+
144
+ const url = `${config.baseUrl}/upload/drive/v3/files?uploadType=multipart&fields=id`;
145
+ return fetch(url, {
146
+ method: 'POST',
147
+ headers: {
148
+ Authorization: 'Bearer ' + config.token,
149
+ 'Content-Type': 'multipart/related; boundary="' + multipartBoundary + '"'
150
+ },
151
+ body
152
+ });
153
+ };
154
+
155
+ // 3. Upload to Folder A
156
+ const resA = await uploadFile(parentIdA);
157
+ expect(resA.status).toBe(200);
158
+ const fileA = await resA.json();
159
+
160
+ // 4. Upload to Folder B (Should succeed)
161
+ const resB = await uploadFile(parentIdB);
162
+
163
+ if (!resB.ok) {
164
+ console.log('Duplicate upload failed:', resB.status, await resB.text());
165
+ }
166
+ expect(resB.status).toBe(200);
167
+ const fileB = await resB.json();
168
+
169
+ // IDs must be different
170
+ expect(fileA.id).not.toBe(fileB.id);
171
+ });
90
172
  });