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.
- package/.agent/rules/project-guidelines.md +5 -0
- package/.github/workflows/release.yml +12 -4
- package/dist/batch.js +1 -1
- package/dist/index.js +1 -1
- package/dist/mappers.d.ts +8 -0
- package/dist/mappers.js +79 -0
- package/dist/routes/v3.js +68 -6
- package/dist/store.js +1 -1
- package/package.json +1 -1
- package/src/batch.ts +2 -1
- package/src/index.ts +1 -1
- package/src/mappers.ts +84 -0
- package/src/routes/v3.ts +71 -5
- package/src/store.ts +1 -1
- package/test/batch_insert_download.test.ts +150 -0
- package/test/concurrent_fetch.test.ts +17 -10
- package/test/dates_and_sorting.test.ts +0 -2
- package/test/folder_query.test.ts +177 -0
- package/test/iterate_changes.test.ts +920 -0
- package/test/parallel_update.test.ts +138 -0
- package/test/url_parameters.test.ts +76 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
|
|
2
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
3
|
+
import { getTestConfig, TestConfig } from './config';
|
|
4
|
+
|
|
5
|
+
describe('Batch Insert and Download Test', () => {
|
|
6
|
+
let config: TestConfig;
|
|
7
|
+
|
|
8
|
+
beforeAll(async () => {
|
|
9
|
+
config = await getTestConfig();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterAll(() => {
|
|
13
|
+
if (config) config.stop();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Helper from user request (adapted)
|
|
17
|
+
async function insertDocumentFiles<RxDocType>(
|
|
18
|
+
googleDriveOptions: { apiEndpoint: string, authToken: string },
|
|
19
|
+
init: { docsFolderId: string },
|
|
20
|
+
primaryPath: string,
|
|
21
|
+
docs: RxDocType[]
|
|
22
|
+
) {
|
|
23
|
+
const boundary = "batch_" + Math.random().toString(16).slice(2);
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
const parts = docs.map((doc, i) => {
|
|
28
|
+
const id = (doc as Record<string, unknown>)[primaryPath] as string;
|
|
29
|
+
const body = JSON.stringify({
|
|
30
|
+
name: id + '.json',
|
|
31
|
+
mimeType: 'application/json',
|
|
32
|
+
parents: [init.docsFolderId],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
`--${boundary}\r\n` +
|
|
37
|
+
`Content-Type: application/http\r\n` +
|
|
38
|
+
`Content-ID: <item-${i}>\r\n\r\n` +
|
|
39
|
+
`POST /drive/v3/files HTTP/1.1\r\n` +
|
|
40
|
+
`Content-Type: application/json; charset=UTF-8\r\n\r\n` +
|
|
41
|
+
`${body}\r\n`
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
const batchBody = parts.join("") + `--${boundary}--`;
|
|
45
|
+
const res = await fetch(googleDriveOptions.apiEndpoint + "/batch/drive/v3", {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: {
|
|
48
|
+
Authorization: `Bearer ${googleDriveOptions.authToken}`,
|
|
49
|
+
"Content-Type": `multipart/mixed; boundary=${boundary}`,
|
|
50
|
+
},
|
|
51
|
+
body: batchBody,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
const text = await res.text().catch(() => "");
|
|
57
|
+
throw new Error(`GDR13: Batch insert failed. Status: ${res.status}. Error: ${text}`);
|
|
58
|
+
}
|
|
59
|
+
const text = await res.text();
|
|
60
|
+
console.log('Batch Response:', text.substring(0, 1000)); // Log snippet
|
|
61
|
+
return text;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
it('should insert docs using batch POST and download them', async () => {
|
|
65
|
+
const docCount = 3;
|
|
66
|
+
const docs = [];
|
|
67
|
+
for (let i = 0; i < docCount; i++) {
|
|
68
|
+
docs.push({ id: `item_${Date.now()}_${i}`, data: `Data ${i}` });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
console.log('Inserting docs via batch POST...');
|
|
72
|
+
const batchResponse = await insertDocumentFiles(
|
|
73
|
+
{ apiEndpoint: config.baseUrl, authToken: config.token },
|
|
74
|
+
{ docsFolderId: config.testFolderId },
|
|
75
|
+
'id',
|
|
76
|
+
docs
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Parse IDs from batch response?
|
|
80
|
+
// The user snippet returns raw text.
|
|
81
|
+
// We usually rely on create returning the created file metadata (including ID).
|
|
82
|
+
// Let's parse the response to get the IDs.
|
|
83
|
+
|
|
84
|
+
const boundaryMatch = (batchResponse.match(/boundary=(.+)/) || [])[1];
|
|
85
|
+
let boundary = boundaryMatch;
|
|
86
|
+
// Or inspect Content-Type header from response? Ideally yes but user helper returns body text.
|
|
87
|
+
// Let's try to detect boundary from body first line if not found?
|
|
88
|
+
// But headers are gone.
|
|
89
|
+
|
|
90
|
+
// Wait, the user helper receives the Response object and returns text().
|
|
91
|
+
// We lose headers :(.
|
|
92
|
+
// Let's assume boundary from body first line.
|
|
93
|
+
const firstLine = batchResponse.trim().split(/\r?\n/)[0];
|
|
94
|
+
if (firstLine.startsWith('--')) {
|
|
95
|
+
boundary = firstLine.substring(2).trim();
|
|
96
|
+
} else {
|
|
97
|
+
// Maybe no boundary in body if empty? unlikely for batch.
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!boundary) throw new Error('Could not detect boundary in batch response');
|
|
101
|
+
|
|
102
|
+
const parts = batchResponse.split(`--${boundary}`);
|
|
103
|
+
const createdIds: string[] = [];
|
|
104
|
+
|
|
105
|
+
for (const part of parts) {
|
|
106
|
+
if (!part.trim() || part.trim() === '--') continue;
|
|
107
|
+
// Find JSON body
|
|
108
|
+
const jsonStart = part.indexOf('{');
|
|
109
|
+
const jsonEnd = part.lastIndexOf('}');
|
|
110
|
+
if (jsonStart !== -1 && jsonEnd !== -1) {
|
|
111
|
+
try {
|
|
112
|
+
const jsonStr = part.substring(jsonStart, jsonEnd + 1);
|
|
113
|
+
const file = JSON.parse(jsonStr);
|
|
114
|
+
if (file.id) {
|
|
115
|
+
createdIds.push(file.id);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
} catch {
|
|
119
|
+
// ignore parse errors
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
expect(createdIds.length).toBe(docCount);
|
|
125
|
+
console.log(`Created ${createdIds.length} files. Downloading...`);
|
|
126
|
+
|
|
127
|
+
for (const fileId of createdIds) {
|
|
128
|
+
const downloadUrl = `${config.baseUrl}/drive/v3/files/${encodeURIComponent(fileId)}?alt=media&supportsAllDrives=true`;
|
|
129
|
+
// console.log(`Downloading ${fileId} from ${downloadUrl}`);
|
|
130
|
+
|
|
131
|
+
const res = await fetch(downloadUrl, {
|
|
132
|
+
headers: { Authorization: `Bearer ${config.token}` }
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
throw new Error(`Download failed for ${fileId}: ${res.status} ${await res.text()}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
const text = await res.text();
|
|
141
|
+
// console.log(`Content for ${fileId}:`, text);
|
|
142
|
+
|
|
143
|
+
// Expect empty content for metadata-only insert?
|
|
144
|
+
// Or maybe Drive defaults to empty JSON object '{}'?
|
|
145
|
+
// Real API behavior for empty file created via metadata POST: 0 bytes.
|
|
146
|
+
expect(text).toBe('');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
}, 30000);
|
|
150
|
+
});
|
|
@@ -41,13 +41,12 @@ export async function batchFetchDocumentContentsRaw(
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
// This will be a multipart/mixed body that you must parse yourself.
|
|
44
|
-
|
|
44
|
+
const text = await res.text();
|
|
45
|
+
console.log('############################# text:');
|
|
46
|
+
console.log(text);
|
|
47
|
+
return text;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
/**
|
|
48
|
-
* Parses a multipart/mixed response body from Google Drive Batch API.
|
|
49
|
-
* Returns an array of objects containing { status, headers, body }.
|
|
50
|
-
*/
|
|
51
50
|
/**
|
|
52
51
|
* Parses a multipart/mixed response body from Google Drive Batch API.
|
|
53
52
|
* Returns an array of objects containing { status, headers, body }.
|
|
@@ -135,7 +134,15 @@ describe('Batch Fetch Test', () => {
|
|
|
135
134
|
if (config) config.stop();
|
|
136
135
|
});
|
|
137
136
|
|
|
138
|
-
|
|
137
|
+
|
|
138
|
+
interface TestContent {
|
|
139
|
+
index: number;
|
|
140
|
+
timestamp: number;
|
|
141
|
+
msg: string;
|
|
142
|
+
random: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function uploadJsonFile(name: string, content: TestContent): Promise<string> {
|
|
139
146
|
const metadata = {
|
|
140
147
|
name: name,
|
|
141
148
|
parents: [config.testFolderId],
|
|
@@ -175,13 +182,13 @@ describe('Batch Fetch Test', () => {
|
|
|
175
182
|
it('should fetch content of many files in a single batch request', async () => {
|
|
176
183
|
const fileCount = 5;
|
|
177
184
|
const fileIds: string[] = [];
|
|
178
|
-
const expectedContents: Record<string,
|
|
185
|
+
const expectedContents: Record<string, TestContent> = {};
|
|
179
186
|
|
|
180
187
|
console.log(`Creating ${fileCount} files...`);
|
|
181
188
|
|
|
182
189
|
for (let i = 0; i < fileCount; i++) {
|
|
183
190
|
const fileName = `BatchFile_${i}_${Date.now()}.json`;
|
|
184
|
-
const content = { index: i, timestamp: Date.now(), msg: `Hello World ${i}`, random: Math.random() };
|
|
191
|
+
const content: TestContent = { index: i, timestamp: Date.now(), msg: `Hello World ${i}`, random: Math.random() };
|
|
185
192
|
|
|
186
193
|
const id = await uploadJsonFile(fileName, content);
|
|
187
194
|
fileIds.push(id);
|
|
@@ -215,7 +222,7 @@ describe('Batch Fetch Test', () => {
|
|
|
215
222
|
expect(parsedResults.length).toBe(fileCount);
|
|
216
223
|
|
|
217
224
|
for (const result of parsedResults) {
|
|
218
|
-
let content = result.body;
|
|
225
|
+
let content = result.body as TestContent;
|
|
219
226
|
|
|
220
227
|
|
|
221
228
|
// All environments (Mock and Real) must return 302 Redirect for alt=media in batch
|
|
@@ -236,7 +243,7 @@ describe('Batch Fetch Test', () => {
|
|
|
236
243
|
});
|
|
237
244
|
|
|
238
245
|
if (!res.ok) throw new Error(`Failed to follow redirect: ${res.status} ${await res.text()}`);
|
|
239
|
-
content = await res.json();
|
|
246
|
+
content = await res.json() as TestContent;
|
|
240
247
|
|
|
241
248
|
|
|
242
249
|
const expected = Object.values(expectedContents).find(c => c.index === content.index);
|
|
@@ -319,7 +319,6 @@ describe('Date Updates and Sorting', () => {
|
|
|
319
319
|
throw new Error(`Create failed: ${createRes.status} ${text}`);
|
|
320
320
|
}
|
|
321
321
|
const file = await createRes.json();
|
|
322
|
-
console.log('Explicit Time Check File:', JSON.stringify(file, null, 2));
|
|
323
322
|
if (!file.modifiedTime) throw new Error('modifiedTime missing from response');
|
|
324
323
|
const modifiedTimeCreate = new Date(file.modifiedTime).getTime();
|
|
325
324
|
|
|
@@ -468,7 +467,6 @@ describe('Date Updates and Sorting', () => {
|
|
|
468
467
|
body: 'Time Check Separate'
|
|
469
468
|
});
|
|
470
469
|
const file = await createRes.json();
|
|
471
|
-
console.log('Explicit Time Check File:', JSON.stringify(file, null, 2));
|
|
472
470
|
if (!file.modifiedTime) throw new Error('modifiedTime missing from response');
|
|
473
471
|
const modifiedTimeCreate = new Date(file.modifiedTime).getTime();
|
|
474
472
|
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
import { getTestConfig, TestConfig } from './config';
|
|
3
|
+
|
|
4
|
+
const randomString = () => Math.random().toString(36).substring(7);
|
|
5
|
+
|
|
6
|
+
describe('Folder Query Investigation', () => {
|
|
7
|
+
let config: TestConfig;
|
|
8
|
+
let headers: Record<string, string>;
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
config = await getTestConfig();
|
|
12
|
+
headers = {
|
|
13
|
+
Authorization: `Bearer ${config.token}`
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should return files in a folder with specific query parameters', async () => {
|
|
18
|
+
// 1. Create a parent folder
|
|
19
|
+
const parentRes = await fetch(`${config.baseUrl}/drive/v3/files`, {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
22
|
+
body: JSON.stringify({
|
|
23
|
+
name: 'QueryTestParams_' + randomString(),
|
|
24
|
+
mimeType: 'application/vnd.google-apps.folder'
|
|
25
|
+
})
|
|
26
|
+
});
|
|
27
|
+
expect(parentRes.status).toBe(200);
|
|
28
|
+
const parentId = (await parentRes.json()).id;
|
|
29
|
+
|
|
30
|
+
// 2. Create a few files in the folder
|
|
31
|
+
const file1 = await fetch(`${config.baseUrl}/drive/v3/files`, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
34
|
+
body: JSON.stringify({
|
|
35
|
+
name: 'file1',
|
|
36
|
+
parents: [parentId]
|
|
37
|
+
})
|
|
38
|
+
}).then(res => res.json());
|
|
39
|
+
|
|
40
|
+
const file2 = await fetch(`${config.baseUrl}/drive/v3/files`, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
name: 'file2',
|
|
45
|
+
parents: [parentId]
|
|
46
|
+
})
|
|
47
|
+
}).then(res => res.json());
|
|
48
|
+
|
|
49
|
+
// Wait for consistency
|
|
50
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
51
|
+
|
|
52
|
+
// 3. Run the user's specific query
|
|
53
|
+
const queryParts = [
|
|
54
|
+
`'${parentId}' in parents`,
|
|
55
|
+
`and trashed = false`
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// Optional: Add modifiedTime check if needed, but let's start with basic structure
|
|
59
|
+
// const checkpoint = { modifiedTime: '...' };
|
|
60
|
+
// if (checkpoint) {
|
|
61
|
+
// queryParts.push(`and modifiedTime >= '${checkpoint.modifiedTime}'`);
|
|
62
|
+
// }
|
|
63
|
+
|
|
64
|
+
const batchSize = 10;
|
|
65
|
+
const params = new URLSearchParams({
|
|
66
|
+
q: queryParts.join(' '),
|
|
67
|
+
pageSize: (batchSize + 10) + '',
|
|
68
|
+
orderBy: "modifiedTime asc,name asc",
|
|
69
|
+
fields: "files(id,name,mimeType,parents,modifiedTime,size)",
|
|
70
|
+
supportsAllDrives: "true",
|
|
71
|
+
includeItemsFromAllDrives: "true",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const url =
|
|
75
|
+
config.baseUrl +
|
|
76
|
+
"/drive/v3/files?" +
|
|
77
|
+
params.toString();
|
|
78
|
+
|
|
79
|
+
console.log('Requesting URL:', url);
|
|
80
|
+
|
|
81
|
+
const res = await fetch(url, {
|
|
82
|
+
headers: {
|
|
83
|
+
Authorization: `Bearer ${config.token}`,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (res.status !== 200) {
|
|
88
|
+
console.error('Error response:', await res.text());
|
|
89
|
+
}
|
|
90
|
+
expect(res.status).toBe(200);
|
|
91
|
+
const data = await res.json();
|
|
92
|
+
|
|
93
|
+
console.log('Found files:', data.files.length);
|
|
94
|
+
const ids = data.files.map((f: { id: string }) => f.id);
|
|
95
|
+
expect(ids).toContain(file1.id);
|
|
96
|
+
expect(ids).toContain(file2.id);
|
|
97
|
+
|
|
98
|
+
// Check if fields are returned
|
|
99
|
+
const f1 = data.files.find((f: { id: string }) => f.id === file1.id) as Record<string, unknown>;
|
|
100
|
+
expect(f1.name).toBeDefined();
|
|
101
|
+
expect(f1.modifiedTime).toBeDefined();
|
|
102
|
+
expect(f1.parents).toBeDefined();
|
|
103
|
+
expect(f1.parents).toContain(parentId);
|
|
104
|
+
|
|
105
|
+
// Strict key check
|
|
106
|
+
const expectedKeys = ['id', 'name', 'mimeType', 'parents', 'modifiedTime', 'size'].sort();
|
|
107
|
+
const actualKeys = Object.keys(f1).sort();
|
|
108
|
+
|
|
109
|
+
// Log for debugging if they don't match (Vitest will show diff)
|
|
110
|
+
if (JSON.stringify(actualKeys) !== JSON.stringify(expectedKeys)) {
|
|
111
|
+
console.log('Keys mismatch! Actual:', actualKeys);
|
|
112
|
+
}
|
|
113
|
+
expect(actualKeys).toEqual(expectedKeys);
|
|
114
|
+
|
|
115
|
+
}, 60000);
|
|
116
|
+
|
|
117
|
+
it('should return files with modifiedTime filter', async () => {
|
|
118
|
+
const parentRes = await fetch(`${config.baseUrl}/drive/v3/files`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
name: 'QueryTestTime_' + randomString(),
|
|
123
|
+
mimeType: 'application/vnd.google-apps.folder'
|
|
124
|
+
})
|
|
125
|
+
});
|
|
126
|
+
const parentId = (await parentRes.json()).id;
|
|
127
|
+
|
|
128
|
+
// Create file 1 (Old)
|
|
129
|
+
const file1 = await fetch(`${config.baseUrl}/drive/v3/files`, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
132
|
+
body: JSON.stringify({ name: 'file1_old', parents: [parentId] })
|
|
133
|
+
}).then(res => res.json());
|
|
134
|
+
|
|
135
|
+
// Wait to ensure distinct modifiedTime
|
|
136
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
137
|
+
|
|
138
|
+
// Checkpoint time
|
|
139
|
+
const checkpointTime = new Date().toISOString();
|
|
140
|
+
|
|
141
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
142
|
+
|
|
143
|
+
// Create file 2 (New)
|
|
144
|
+
const file2 = await fetch(`${config.baseUrl}/drive/v3/files`, {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
147
|
+
body: JSON.stringify({ name: 'file2_new', parents: [parentId] })
|
|
148
|
+
}).then(res => res.json());
|
|
149
|
+
|
|
150
|
+
// Query: parent + trashed + modifiedTime >= checkpoint
|
|
151
|
+
const queryParts = [
|
|
152
|
+
`'${parentId}' in parents`,
|
|
153
|
+
`and trashed = false`,
|
|
154
|
+
`and modifiedTime >= '${checkpointTime}'`
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
const params = new URLSearchParams({
|
|
158
|
+
q: queryParts.join(' '),
|
|
159
|
+
pageSize: '10',
|
|
160
|
+
orderBy: "modifiedTime asc,name asc",
|
|
161
|
+
fields: "files(id,name,mimeType,parents,modifiedTime,size)",
|
|
162
|
+
supportsAllDrives: "true",
|
|
163
|
+
includeItemsFromAllDrives: "true",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const url = `${config.baseUrl}/drive/v3/files?${params.toString()}`;
|
|
167
|
+
const res = await fetch(url, { headers: { Authorization: `Bearer ${config.token}` } });
|
|
168
|
+
expect(res.status).toBe(200);
|
|
169
|
+
const data = await res.json();
|
|
170
|
+
|
|
171
|
+
const ids = data.files.map((f: { id: string }) => f.id);
|
|
172
|
+
|
|
173
|
+
// Should contain file2 (New), but NOT file1 (Old)
|
|
174
|
+
expect(ids).toContain(file2.id);
|
|
175
|
+
expect(ids).not.toContain(file1.id);
|
|
176
|
+
}, 60000);
|
|
177
|
+
});
|