google-drive-mock 1.0.13 → 1.1.1

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.
@@ -18,6 +18,11 @@ When working on this project, always adhere to the following workflow to ensure
18
18
  - Run this **AFTER** verifying the tests against the Real API.
19
19
  - This ensures that the Mock server implementation correctly handles the now-verified tests.
20
20
 
21
+ 4. **Ensure build works**
22
+ - Run "npm run lint"
23
+ - Run "npm run build"
24
+
25
+
21
26
 
22
27
  No Goes:
23
28
 
@@ -4,8 +4,8 @@ on:
4
4
  workflow_dispatch:
5
5
  inputs:
6
6
  version:
7
- description: 'New Version (e.g. 1.0.0)'
8
- required: true
7
+ description: 'New Version (e.g. 1.0.0, or leave empty for patch)'
8
+ required: false
9
9
  type: string
10
10
 
11
11
  jobs:
@@ -25,7 +25,15 @@ jobs:
25
25
  cache: 'npm'
26
26
 
27
27
  - run: npm install
28
- - run: npm version ${{ inputs.version }} --no-git-tag-version
28
+ - name: Bump Version
29
+ id: bump
30
+ run: |
31
+ if [ -z "${{ inputs.version }}" ]; then
32
+ VERSION=$(npm version patch --no-git-tag-version)
33
+ else
34
+ VERSION=$(npm version ${{ inputs.version }} --no-git-tag-version)
35
+ fi
36
+ echo "version=${VERSION}" >> $GITHUB_OUTPUT
29
37
  - run: npm run build
30
38
  - run: npm run lint
31
39
  - run: npm test
@@ -38,5 +46,5 @@ jobs:
38
46
  git config --global user.name 'github-actions[bot]'
39
47
  git config --global user.email 'github-actions[bot]@users.noreply.github.com'
40
48
  git add package.json package-lock.json
41
- git commit -m "release: ${{ inputs.version }}"
49
+ git commit -m "release: ${{ steps.bump.outputs.version }}"
42
50
  git push
package/dist/batch.js CHANGED
@@ -100,7 +100,7 @@ function processPart(part, req) {
100
100
  body: Object.assign({ kind: "drive#about" }, about)
101
101
  };
102
102
  }
103
- // POST Create File
103
+ // POST Create File (Standard)
104
104
  if (part.method === 'POST' && filesListMatch) {
105
105
  if (!part.body || !part.body.name) {
106
106
  return { contentId: part.contentId, statusCode: 400, body: { error: { code: 400, message: 'Name required' } } };
package/dist/index.js CHANGED
@@ -26,7 +26,7 @@ const createApp = (config = {}) => {
26
26
  }
27
27
  const app = (0, express_1.default)();
28
28
  app.use((0, cors_1.default)({
29
- exposedHeaders: ['ETag']
29
+ exposedHeaders: ['ETag', 'Date', 'Content-Length']
30
30
  }));
31
31
  app.set('etag', false); // Disable default ETag generation to match Real API behavior
32
32
  app.use((req, res, next) => __awaiter(void 0, void 0, void 0, function* () {
package/dist/mappers.d.ts CHANGED
@@ -7,3 +7,11 @@ export declare function toV2File(file: DriveFile): Record<string, unknown>;
7
7
  * Maps a V2 API File Update/Insert body to a partial Internal DriveFile (V3 format).
8
8
  */
9
9
  export declare function fromV2Update(body: Record<string, unknown>): Partial<DriveFile>;
10
+ /**
11
+ * Filters an object based on the Google Drive API `fields` parameter.
12
+ * Supports nested fields like "files(id,name,parents)".
13
+ *
14
+ * @param data The object to filter
15
+ * @param fields The fields selection string
16
+ */
17
+ export declare function applyFields(data: unknown, fields: string): unknown;
package/dist/mappers.js CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.toV2File = toV2File;
4
4
  exports.fromV2Update = fromV2Update;
5
+ exports.applyFields = applyFields;
5
6
  /**
6
7
  * Maps an internal DriveFile (V3 format) to a V2 API File resource.
7
8
  */
@@ -61,3 +62,81 @@ function fromV2Update(body) {
61
62
  }
62
63
  return update;
63
64
  }
65
+ /**
66
+ * Filters an object based on the Google Drive API `fields` parameter.
67
+ * Supports nested fields like "files(id,name,parents)".
68
+ *
69
+ * @param data The object to filter
70
+ * @param fields The fields selection string
71
+ */
72
+ function applyFields(data, fields) {
73
+ if (!fields || fields === '*')
74
+ return data;
75
+ // Parse top-level fields
76
+ const parsedFields = [];
77
+ let depth = 0;
78
+ let currentStart = 0;
79
+ for (let i = 0; i < fields.length; i++) {
80
+ const char = fields[i];
81
+ if (char === '(')
82
+ depth++;
83
+ else if (char === ')')
84
+ depth--;
85
+ else if (char === ',' && depth === 0) {
86
+ // Split
87
+ parseFieldPart(fields.substring(currentStart, i), parsedFields);
88
+ currentStart = i + 1;
89
+ }
90
+ }
91
+ // Last part
92
+ parseFieldPart(fields.substring(currentStart), parsedFields);
93
+ if (Array.isArray(data)) {
94
+ // If data is array, apply to each item?
95
+ // Usually fields selection on array applies to the object WRAPPING the array,
96
+ // e.g. "files(id)" on { files: [...] }.
97
+ // But if we are called RESURSIVELY on an array value, we should map it.
98
+ return data.map(item => applyFields(item, fields));
99
+ }
100
+ if (typeof data !== 'object' || data === null) {
101
+ return data;
102
+ }
103
+ const dataObj = data;
104
+ const result = {};
105
+ for (const field of parsedFields) {
106
+ if (field.key === '*') {
107
+ // Wildcard at this level - copy everything?
108
+ // Logic can be complex. For now, strict parity with requested use case.
109
+ return data;
110
+ }
111
+ if (Object.prototype.hasOwnProperty.call(dataObj, field.key)) {
112
+ const value = dataObj[field.key];
113
+ if (field.subFields) {
114
+ // Recursive apply
115
+ if (Array.isArray(value)) {
116
+ result[field.key] = value.map((item) => applyFields(item, field.subFields));
117
+ }
118
+ else {
119
+ result[field.key] = applyFields(value, field.subFields);
120
+ }
121
+ }
122
+ else {
123
+ result[field.key] = value;
124
+ }
125
+ }
126
+ }
127
+ return result;
128
+ }
129
+ function parseFieldPart(part, result) {
130
+ const trimmed = part.trim();
131
+ if (!trimmed)
132
+ return;
133
+ const parenStart = trimmed.indexOf('(');
134
+ if (parenStart !== -1 && trimmed.endsWith(')')) {
135
+ const key = trimmed.substring(0, parenStart);
136
+ const subFields = trimmed.substring(parenStart + 1, trimmed.length - 1);
137
+ result.push({ key, subFields });
138
+ }
139
+ else {
140
+ result.push({ key: trimmed });
141
+ }
142
+ }
package/dist/routes/v3.js CHANGED
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createV3Router = void 0;
7
7
  const express_1 = __importDefault(require("express"));
8
8
  const store_1 = require("../store");
9
+ const mappers_1 = require("../mappers");
9
10
  const createV3Router = () => {
10
11
  const app = express_1.default.Router();
11
12
  // About
@@ -22,7 +23,7 @@ const createV3Router = () => {
22
23
  // Enhanced query parser for Mock
23
24
  // Recursive function to handle nested OR/AND logic with parens
24
25
  const evaluateQuery = (queryStr, file) => {
25
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
26
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
26
27
  const str = queryStr.trim();
27
28
  if (!str)
28
29
  return true;
@@ -136,6 +137,14 @@ const createV3Router = () => {
136
137
  const timeStr = (_k = part.match(/modifiedTime < '(.*)'/)) === null || _k === void 0 ? void 0 : _k[1];
137
138
  return !!(timeStr && new Date(file.modifiedTime) < new Date(timeStr));
138
139
  }
140
+ if (part.startsWith("modifiedTime = '")) {
141
+ const timeStr = (_l = part.match(/modifiedTime = '(.*)'/)) === null || _l === void 0 ? void 0 : _l[1];
142
+ return !!(timeStr && new Date(file.modifiedTime).toISOString() === new Date(timeStr).toISOString());
143
+ }
144
+ if (part.startsWith("modifiedTime >= '")) {
145
+ const timeStr = (_m = part.match(/modifiedTime >= '(.*)'/)) === null || _m === void 0 ? void 0 : _m[1];
146
+ return !!(timeStr && new Date(file.modifiedTime) >= new Date(timeStr));
147
+ }
139
148
  // Fallback / Unknown
140
149
  return true;
141
150
  };
@@ -172,11 +181,41 @@ const createV3Router = () => {
172
181
  return 0;
173
182
  });
174
183
  }
175
- res.json({
184
+ // Pagination
185
+ const pageSize = req.query.pageSize ? parseInt(req.query.pageSize, 10) : 100; // Default 100
186
+ let skip = 0;
187
+ if (req.query.pageToken) {
188
+ try {
189
+ const tokenJson = Buffer.from(req.query.pageToken, 'base64').toString('utf-8');
190
+ const tokenData = JSON.parse(tokenJson);
191
+ if (typeof tokenData.skip === 'number') {
192
+ skip = tokenData.skip;
193
+ }
194
+ }
195
+ catch (_a) {
196
+ // Ignore invalid token, start from 0
197
+ }
198
+ }
199
+ const totalFiles = files.length;
200
+ const resultFiles = files.slice(skip, skip + pageSize);
201
+ let nextPageToken;
202
+ if (skip + pageSize < totalFiles) {
203
+ const nextSkip = skip + pageSize;
204
+ nextPageToken = Buffer.from(JSON.stringify({ skip: nextSkip })).toString('base64');
205
+ }
206
+ const response = {
176
207
  kind: "drive#fileList",
177
208
  incompleteSearch: false,
178
- files: files
179
- });
209
+ files: resultFiles,
210
+ nextPageToken
211
+ };
212
+ const fields = req.query.fields;
213
+ if (fields) {
214
+ res.json((0, mappers_1.applyFields)(response, fields));
215
+ }
216
+ else {
217
+ res.json(response);
218
+ }
180
219
  });
181
220
  // Changes: Get Start Page Token
182
221
  app.get('/drive/v3/changes/startPageToken', (req, res) => {
@@ -419,8 +458,10 @@ const createV3Router = () => {
419
458
  res.status(400).send("Invalid file ID");
420
459
  return;
421
460
  }
422
- const updates = req.body;
423
- if (!updates) {
461
+ const updates = req.body || {};
462
+ const hasBody = Object.keys(updates).length > 0;
463
+ const hasQueryParams = req.query.addParents || req.query.removeParents;
464
+ if (!hasBody && !hasQueryParams) {
424
465
  res.status(400).json({ error: { code: 400, message: "Bad Request: No updates provided" } });
425
466
  return;
426
467
  }
@@ -429,6 +470,27 @@ const createV3Router = () => {
429
470
  res.status(404).json({ error: { code: 404, message: "File not found" } });
430
471
  return;
431
472
  }
473
+ const addParents = req.query.addParents;
474
+ if (addParents) {
475
+ const parentsToAdd = addParents.split(',');
476
+ const currentParents = updatedFile.parents || [];
477
+ const newParents = [...new Set([...currentParents, ...parentsToAdd])]; // Union
478
+ // Update the file with new parents
479
+ const result = store_1.driveStore.updateFile(fileId, { parents: newParents });
480
+ if (result) {
481
+ Object.assign(updatedFile, result);
482
+ }
483
+ }
484
+ const removeParents = req.query.removeParents;
485
+ if (removeParents) {
486
+ const parentsToRemove = removeParents.split(',');
487
+ const currentParents = updatedFile.parents || [];
488
+ const newParents = currentParents.filter(p => !parentsToRemove.includes(p));
489
+ const result = store_1.driveStore.updateFile(fileId, { parents: newParents });
490
+ if (result) {
491
+ Object.assign(updatedFile, result);
492
+ }
493
+ }
432
494
  res.json(updatedFile);
433
495
  });
434
496
  // Files: Delete
package/dist/store.js CHANGED
@@ -81,7 +81,7 @@ class DriveStore {
81
81
  }
82
82
  // Merge updates and increment version
83
83
  const newVersion = file.version + 1;
84
- const updatedFile = Object.assign(Object.assign(Object.assign(Object.assign({}, file), updates), statsUpdates), { version: newVersion, etag: String(newVersion), modifiedTime: new Date().toISOString() });
84
+ const updatedFile = Object.assign(Object.assign(Object.assign(Object.assign({}, file), updates), statsUpdates), { version: newVersion, etag: String(newVersion), modifiedTime: updates.modifiedTime || new Date().toISOString() });
85
85
  this.files.set(id, updatedFile);
86
86
  this.addChange(updatedFile);
87
87
  return updatedFile;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "google-drive-mock",
3
- "version": "1.0.13",
3
+ "version": "1.1.1",
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/batch.ts CHANGED
@@ -133,7 +133,8 @@ function processPart(part: BatchPart, req: Request): BatchResponse {
133
133
  };
134
134
  }
135
135
 
136
- // POST Create File
136
+
137
+ // POST Create File (Standard)
137
138
  if (part.method === 'POST' && filesListMatch) {
138
139
  if (!part.body || !part.body.name) {
139
140
  return { contentId: part.contentId, statusCode: 400, body: { error: { code: 400, message: 'Name required' } } };
package/src/index.ts CHANGED
@@ -14,7 +14,7 @@ const createApp = (config: AppConfig = {}) => {
14
14
 
15
15
  const app = express();
16
16
  app.use(cors({
17
- exposedHeaders: ['ETag']
17
+ exposedHeaders: ['ETag', 'Date', 'Content-Length']
18
18
  }));
19
19
  app.set('etag', false); // Disable default ETag generation to match Real API behavior
20
20
 
package/src/mappers.ts CHANGED
@@ -59,3 +59,87 @@ export function fromV2Update(body: Record<string, unknown>): Partial<DriveFile>
59
59
 
60
60
  return update;
61
61
  }
62
+
63
+
64
+ /**
65
+ * Filters an object based on the Google Drive API `fields` parameter.
66
+ * Supports nested fields like "files(id,name,parents)".
67
+ *
68
+ * @param data The object to filter
69
+ * @param fields The fields selection string
70
+ */
71
+ export function applyFields(data: unknown, fields: string): unknown {
72
+ if (!fields || fields === '*') return data;
73
+
74
+ // Parse top-level fields
75
+ const parsedFields: { key: string, subFields?: string }[] = [];
76
+
77
+ let depth = 0;
78
+ let currentStart = 0;
79
+
80
+ for (let i = 0; i < fields.length; i++) {
81
+ const char = fields[i];
82
+ if (char === '(') depth++;
83
+ else if (char === ')') depth--;
84
+ else if (char === ',' && depth === 0) {
85
+ // Split
86
+ parseFieldPart(fields.substring(currentStart, i), parsedFields);
87
+ currentStart = i + 1;
88
+ }
89
+ }
90
+ // Last part
91
+ parseFieldPart(fields.substring(currentStart), parsedFields);
92
+
93
+ if (Array.isArray(data)) {
94
+ // If data is array, apply to each item?
95
+ // Usually fields selection on array applies to the object WRAPPING the array,
96
+ // e.g. "files(id)" on { files: [...] }.
97
+ // But if we are called RESURSIVELY on an array value, we should map it.
98
+ return data.map(item => applyFields(item, fields));
99
+ }
100
+
101
+ if (typeof data !== 'object' || data === null) {
102
+ return data;
103
+ }
104
+
105
+ const dataObj = data as Record<string, unknown>;
106
+ const result: Record<string, unknown> = {};
107
+
108
+ for (const field of parsedFields) {
109
+ if (field.key === '*') {
110
+ // Wildcard at this level - copy everything?
111
+ // Logic can be complex. For now, strict parity with requested use case.
112
+ return data;
113
+ }
114
+
115
+ if (Object.prototype.hasOwnProperty.call(dataObj, field.key)) {
116
+ const value = dataObj[field.key];
117
+ if (field.subFields) {
118
+ // Recursive apply
119
+ if (Array.isArray(value)) {
120
+ result[field.key] = value.map((item: unknown) => applyFields(item, field.subFields!));
121
+ } else {
122
+ result[field.key] = applyFields(value, field.subFields!);
123
+ }
124
+ } else {
125
+ result[field.key] = value;
126
+ }
127
+ }
128
+ }
129
+
130
+ return result;
131
+ }
132
+
133
+ function parseFieldPart(part: string, result: { key: string, subFields?: string }[]) {
134
+ const trimmed = part.trim();
135
+ if (!trimmed) return;
136
+
137
+ const parenStart = trimmed.indexOf('(');
138
+ if (parenStart !== -1 && trimmed.endsWith(')')) {
139
+ const key = trimmed.substring(0, parenStart);
140
+ const subFields = trimmed.substring(parenStart + 1, trimmed.length - 1);
141
+ result.push({ key, subFields });
142
+ } else {
143
+ result.push({ key: trimmed });
144
+ }
145
+ }
package/src/routes/v3.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import express, { Request, Response } from 'express';
2
2
  import { driveStore } from '../store';
3
+ import { applyFields } from '../mappers';
3
4
 
4
5
  export const createV3Router = () => {
5
6
  const app = express.Router();
@@ -136,6 +137,14 @@ export const createV3Router = () => {
136
137
  const timeStr = part.match(/modifiedTime < '(.*)'/)?.[1];
137
138
  return !!(timeStr && new Date(file.modifiedTime) < new Date(timeStr));
138
139
  }
140
+ if (part.startsWith("modifiedTime = '")) {
141
+ const timeStr = part.match(/modifiedTime = '(.*)'/)?.[1];
142
+ return !!(timeStr && new Date(file.modifiedTime).toISOString() === new Date(timeStr).toISOString());
143
+ }
144
+ if (part.startsWith("modifiedTime >= '")) {
145
+ const timeStr = part.match(/modifiedTime >= '(.*)'/)?.[1];
146
+ return !!(timeStr && new Date(file.modifiedTime) >= new Date(timeStr));
147
+ }
139
148
 
140
149
  // Fallback / Unknown
141
150
  return true;
@@ -177,11 +186,43 @@ export const createV3Router = () => {
177
186
  });
178
187
  }
179
188
 
180
- res.json({
189
+ // Pagination
190
+ const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string, 10) : 100; // Default 100
191
+ let skip = 0;
192
+ if (req.query.pageToken) {
193
+ try {
194
+ const tokenJson = Buffer.from(req.query.pageToken as string, 'base64').toString('utf-8');
195
+ const tokenData = JSON.parse(tokenJson);
196
+ if (typeof tokenData.skip === 'number') {
197
+ skip = tokenData.skip;
198
+ }
199
+ } catch {
200
+ // Ignore invalid token, start from 0
201
+ }
202
+ }
203
+
204
+ const totalFiles = files.length;
205
+ const resultFiles = files.slice(skip, skip + pageSize);
206
+
207
+ let nextPageToken: string | undefined;
208
+ if (skip + pageSize < totalFiles) {
209
+ const nextSkip = skip + pageSize;
210
+ nextPageToken = Buffer.from(JSON.stringify({ skip: nextSkip })).toString('base64');
211
+ }
212
+
213
+ const response: Record<string, unknown> = {
181
214
  kind: "drive#fileList",
182
215
  incompleteSearch: false,
183
- files: files
184
- });
216
+ files: resultFiles,
217
+ nextPageToken
218
+ };
219
+
220
+ const fields = req.query.fields as string;
221
+ if (fields) {
222
+ res.json(applyFields(response, fields));
223
+ } else {
224
+ res.json(response);
225
+ }
185
226
  });
186
227
 
187
228
  // Changes: Get Start Page Token
@@ -474,9 +515,11 @@ export const createV3Router = () => {
474
515
  res.status(400).send("Invalid file ID");
475
516
  return;
476
517
  }
477
- const updates = req.body;
518
+ const updates = req.body || {};
519
+ const hasBody = Object.keys(updates).length > 0;
520
+ const hasQueryParams = req.query.addParents || req.query.removeParents;
478
521
 
479
- if (!updates) {
522
+ if (!hasBody && !hasQueryParams) {
480
523
  res.status(400).json({ error: { code: 400, message: "Bad Request: No updates provided" } });
481
524
  return;
482
525
  }
@@ -488,6 +531,29 @@ export const createV3Router = () => {
488
531
  return;
489
532
  }
490
533
 
534
+ const addParents = req.query.addParents as string;
535
+ if (addParents) {
536
+ const parentsToAdd = addParents.split(',');
537
+ const currentParents = updatedFile.parents || [];
538
+ const newParents = [...new Set([...currentParents, ...parentsToAdd])]; // Union
539
+ // Update the file with new parents
540
+ const result = driveStore.updateFile(fileId, { parents: newParents });
541
+ if (result) {
542
+ Object.assign(updatedFile, result);
543
+ }
544
+ }
545
+
546
+ const removeParents = req.query.removeParents as string;
547
+ if (removeParents) {
548
+ const parentsToRemove = removeParents.split(',');
549
+ const currentParents = updatedFile.parents || [];
550
+ const newParents = currentParents.filter(p => !parentsToRemove.includes(p));
551
+ const result = driveStore.updateFile(fileId, { parents: newParents });
552
+ if (result) {
553
+ Object.assign(updatedFile, result);
554
+ }
555
+ }
556
+
491
557
  res.json(updatedFile);
492
558
  });
493
559
 
package/src/store.ts CHANGED
@@ -114,7 +114,7 @@ export class DriveStore {
114
114
  ...statsUpdates,
115
115
  version: newVersion,
116
116
  etag: String(newVersion),
117
- modifiedTime: new Date().toISOString()
117
+ modifiedTime: updates.modifiedTime || new Date().toISOString()
118
118
  };
119
119
  this.files.set(id, updatedFile);
120
120
  this.addChange(updatedFile);