google-drive-mock 0.0.1 → 1.0.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/.github/workflows/release.yml +20 -2
- package/AGENTS.md +1 -1
- package/dist/index.js +127 -6
- package/dist/store.d.ts +19 -0
- package/dist/store.js +45 -3
- package/package.json +2 -2
- package/src/index.ts +129 -1
- package/src/store.ts +67 -2
- package/test/advanced.test.ts +175 -0
- package/test/config.ts +4 -0
- package/test/features.test.ts +245 -0
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
name: Release
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
version:
|
|
7
|
+
description: 'New Version (e.g. 1.0.0)'
|
|
8
|
+
required: true
|
|
9
|
+
type: string
|
|
6
10
|
|
|
7
11
|
jobs:
|
|
8
12
|
build:
|
|
9
13
|
runs-on: ubuntu-24.04
|
|
14
|
+
# @link https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs
|
|
15
|
+
permissions:
|
|
16
|
+
contents: write
|
|
17
|
+
# @link https://docs.npmjs.com/generating-provenance-statements#about-npm-provenance
|
|
18
|
+
id-token: write
|
|
10
19
|
steps:
|
|
11
20
|
- uses: actions/checkout@v4
|
|
12
21
|
- uses: actions/setup-node@v4
|
|
@@ -16,9 +25,18 @@ jobs:
|
|
|
16
25
|
cache: 'npm'
|
|
17
26
|
|
|
18
27
|
- run: npm install
|
|
28
|
+
- run: npm version ${{ inputs.version }} --no-git-tag-version
|
|
19
29
|
- run: npm run build
|
|
20
30
|
- run: npm run lint
|
|
21
31
|
- run: npm test
|
|
22
32
|
- run: npm publish
|
|
23
33
|
env:
|
|
24
34
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
35
|
+
|
|
36
|
+
- name: Push changes
|
|
37
|
+
run: |
|
|
38
|
+
git config --global user.name 'github-actions[bot]'
|
|
39
|
+
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
|
40
|
+
git add package.json package-lock.json
|
|
41
|
+
git commit -m "release: ${{ inputs.version }}"
|
|
42
|
+
git push
|
package/AGENTS.md
CHANGED
package/dist/index.js
CHANGED
|
@@ -14,10 +14,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
15
|
exports.startServer = exports.createApp = void 0;
|
|
16
16
|
const express_1 = __importDefault(require("express"));
|
|
17
|
+
const cors_1 = __importDefault(require("cors"));
|
|
17
18
|
const store_1 = require("./store");
|
|
18
19
|
const batch_1 = require("./batch");
|
|
19
20
|
const createApp = (config = {}) => {
|
|
20
21
|
const app = (0, express_1.default)();
|
|
22
|
+
app.use((0, cors_1.default)());
|
|
21
23
|
app.use((req, res, next) => __awaiter(void 0, void 0, void 0, function* () {
|
|
22
24
|
if (config.serverLagBefore && config.serverLagBefore > 0) {
|
|
23
25
|
yield new Promise(resolve => setTimeout(resolve, config.serverLagBefore));
|
|
@@ -42,6 +44,10 @@ const createApp = (config = {}) => {
|
|
|
42
44
|
store_1.driveStore.clear();
|
|
43
45
|
res.status(200).send('Cleared');
|
|
44
46
|
});
|
|
47
|
+
// Health Check
|
|
48
|
+
app.get('/', (req, res) => {
|
|
49
|
+
res.status(200).send('OK');
|
|
50
|
+
});
|
|
45
51
|
// Auth Middleware
|
|
46
52
|
const validTokens = ['valid-token', 'another-valid-token'];
|
|
47
53
|
app.use((req, res, next) => {
|
|
@@ -65,13 +71,132 @@ const createApp = (config = {}) => {
|
|
|
65
71
|
});
|
|
66
72
|
// Files: List
|
|
67
73
|
app.get('/drive/v3/files', (req, res) => {
|
|
68
|
-
|
|
74
|
+
let files = store_1.driveStore.listFiles();
|
|
75
|
+
const q = req.query.q;
|
|
76
|
+
const orderBy = req.query.orderBy;
|
|
77
|
+
if (q) {
|
|
78
|
+
// Enhanced query parser for Mock
|
|
79
|
+
// Supports:
|
|
80
|
+
// - name = '...'
|
|
81
|
+
// - mimeType = '...'
|
|
82
|
+
// - trashed = true/false
|
|
83
|
+
// - 'ID' in parents
|
|
84
|
+
// - name contains '...'
|
|
85
|
+
const parts = q.split(' and ').map(p => p.trim());
|
|
86
|
+
files = files.filter(file => {
|
|
87
|
+
return parts.every(part => {
|
|
88
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
89
|
+
// name = '...'
|
|
90
|
+
if (part.startsWith("name = '")) {
|
|
91
|
+
const name = (_a = part.match(/name = '(.*)'/)) === null || _a === void 0 ? void 0 : _a[1];
|
|
92
|
+
return file.name === name;
|
|
93
|
+
}
|
|
94
|
+
// name contains '...'
|
|
95
|
+
if (part.startsWith("name contains '")) {
|
|
96
|
+
const token = (_b = part.match(/name contains '(.*)'/)) === null || _b === void 0 ? void 0 : _b[1];
|
|
97
|
+
return token && file.name.includes(token);
|
|
98
|
+
}
|
|
99
|
+
// 'ID' in parents
|
|
100
|
+
if (part.includes(" in parents")) {
|
|
101
|
+
const parentId = (_c = part.match(/'(.*)' in parents/)) === null || _c === void 0 ? void 0 : _c[1];
|
|
102
|
+
return parentId && ((_d = file.parents) === null || _d === void 0 ? void 0 : _d.includes(parentId));
|
|
103
|
+
}
|
|
104
|
+
// trashed = ...
|
|
105
|
+
if (part === "trashed = false") {
|
|
106
|
+
return file.trashed !== true;
|
|
107
|
+
}
|
|
108
|
+
if (part === "trashed = true") {
|
|
109
|
+
return file.trashed === true;
|
|
110
|
+
}
|
|
111
|
+
// mimeType = '...'
|
|
112
|
+
if (part.startsWith("mimeType = '")) {
|
|
113
|
+
const mime = (_e = part.match(/mimeType = '(.*)'/)) === null || _e === void 0 ? void 0 : _e[1];
|
|
114
|
+
return file.mimeType === mime;
|
|
115
|
+
}
|
|
116
|
+
// mimeType != '...'
|
|
117
|
+
if (part.startsWith("mimeType != '")) {
|
|
118
|
+
const mime = (_f = part.match(/mimeType != '(.*)'/)) === null || _f === void 0 ? void 0 : _f[1];
|
|
119
|
+
return file.mimeType !== mime;
|
|
120
|
+
}
|
|
121
|
+
// modifiedTime > '...'
|
|
122
|
+
if (part.startsWith("modifiedTime > '")) {
|
|
123
|
+
const timeStr = (_g = part.match(/modifiedTime > '(.*)'/)) === null || _g === void 0 ? void 0 : _g[1];
|
|
124
|
+
return timeStr && new Date(file.modifiedTime) > new Date(timeStr);
|
|
125
|
+
}
|
|
126
|
+
// modifiedTime < '...'
|
|
127
|
+
if (part.startsWith("modifiedTime < '")) {
|
|
128
|
+
const timeStr = (_h = part.match(/modifiedTime < '(.*)'/)) === null || _h === void 0 ? void 0 : _h[1];
|
|
129
|
+
return timeStr && new Date(file.modifiedTime) < new Date(timeStr);
|
|
130
|
+
}
|
|
131
|
+
// Ignore unknown filters for now
|
|
132
|
+
return true;
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// Sorting (orderBy)
|
|
137
|
+
if (orderBy) {
|
|
138
|
+
// Basic support for single keys: 'folder,name', 'modifiedTime desc', etc.
|
|
139
|
+
// Splitting by comma
|
|
140
|
+
const sortKeys = orderBy.split(',').map(k => k.trim());
|
|
141
|
+
files.sort((a, b) => {
|
|
142
|
+
for (const keyDef of sortKeys) {
|
|
143
|
+
const [key, direction] = keyDef.split(' ');
|
|
144
|
+
const dir = direction || 'asc';
|
|
145
|
+
// Handle special virtual key 'folder'
|
|
146
|
+
if (key === 'folder') {
|
|
147
|
+
const aIsFolder = a.mimeType === 'application/vnd.google-apps.folder';
|
|
148
|
+
const bIsFolder = b.mimeType === 'application/vnd.google-apps.folder';
|
|
149
|
+
if (aIsFolder !== bIsFolder) {
|
|
150
|
+
// Folders first in 'folder' sort usually?
|
|
151
|
+
// Google docs say: "folder sets folders to appear before..."
|
|
152
|
+
const valA = aIsFolder ? 0 : 1;
|
|
153
|
+
const valB = bIsFolder ? 0 : 1;
|
|
154
|
+
if (valA !== valB)
|
|
155
|
+
return dir === 'desc' ? valB - valA : valA - valB;
|
|
156
|
+
}
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const valA = a[key];
|
|
160
|
+
const valB = b[key];
|
|
161
|
+
if (valA === undefined || valB === undefined)
|
|
162
|
+
return 0;
|
|
163
|
+
if (valA < valB)
|
|
164
|
+
return dir === 'desc' ? 1 : -1;
|
|
165
|
+
if (valA > valB)
|
|
166
|
+
return dir === 'desc' ? -1 : 1;
|
|
167
|
+
}
|
|
168
|
+
return 0;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
69
171
|
res.json({
|
|
70
172
|
kind: "drive#fileList",
|
|
71
173
|
incompleteSearch: false,
|
|
72
174
|
files: files
|
|
73
175
|
});
|
|
74
176
|
});
|
|
177
|
+
// Changes: Get Start Page Token
|
|
178
|
+
app.get('/drive/v3/changes/startPageToken', (req, res) => {
|
|
179
|
+
const token = store_1.driveStore.getStartPageToken();
|
|
180
|
+
res.json({
|
|
181
|
+
kind: "drive#startPageToken",
|
|
182
|
+
startPageToken: token
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
// Changes: List
|
|
186
|
+
app.get('/drive/v3/changes', (req, res) => {
|
|
187
|
+
const pageToken = req.query.pageToken;
|
|
188
|
+
if (!pageToken) {
|
|
189
|
+
res.status(400).json({ error: { code: 400, message: "Bad Request: pageToken is required" } });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const result = store_1.driveStore.getChanges(pageToken);
|
|
193
|
+
res.json({
|
|
194
|
+
kind: "drive#changeList",
|
|
195
|
+
newStartPageToken: result.newStartPageToken,
|
|
196
|
+
nextPageToken: result.nextPageToken,
|
|
197
|
+
changes: result.changes
|
|
198
|
+
});
|
|
199
|
+
});
|
|
75
200
|
// Files: Create
|
|
76
201
|
app.post('/drive/v3/files', (req, res) => {
|
|
77
202
|
const body = req.body;
|
|
@@ -85,11 +210,7 @@ const createApp = (config = {}) => {
|
|
|
85
210
|
res.status(409).json({ error: { code: 409, message: "Conflict: File with same name already exists" } });
|
|
86
211
|
return;
|
|
87
212
|
}
|
|
88
|
-
const newFile = store_1.driveStore.createFile({
|
|
89
|
-
name: body.name,
|
|
90
|
-
mimeType: body.mimeType || "application/octet-stream",
|
|
91
|
-
parents: body.parents || []
|
|
92
|
-
});
|
|
213
|
+
const newFile = store_1.driveStore.createFile(Object.assign(Object.assign({}, body), { name: body.name, mimeType: body.mimeType || "application/octet-stream", parents: body.parents || [] }));
|
|
93
214
|
res.status(200).json(newFile);
|
|
94
215
|
});
|
|
95
216
|
// Files: Get
|
package/dist/store.d.ts
CHANGED
|
@@ -5,10 +5,22 @@ export interface DriveFile {
|
|
|
5
5
|
kind: string;
|
|
6
6
|
parents?: string[];
|
|
7
7
|
version: number;
|
|
8
|
+
trashed: boolean;
|
|
9
|
+
createdTime: string;
|
|
10
|
+
modifiedTime: string;
|
|
8
11
|
[key: string]: unknown;
|
|
9
12
|
}
|
|
13
|
+
export interface DriveChange {
|
|
14
|
+
kind: "drive#change";
|
|
15
|
+
changeType: "file" | "drive";
|
|
16
|
+
time: string;
|
|
17
|
+
removed: boolean;
|
|
18
|
+
fileId: string;
|
|
19
|
+
file?: DriveFile;
|
|
20
|
+
}
|
|
10
21
|
export declare class DriveStore {
|
|
11
22
|
private files;
|
|
23
|
+
private changes;
|
|
12
24
|
constructor();
|
|
13
25
|
createFile(file: Partial<DriveFile> & {
|
|
14
26
|
name: string;
|
|
@@ -19,5 +31,12 @@ export declare class DriveStore {
|
|
|
19
31
|
listFiles(): DriveFile[];
|
|
20
32
|
clear(): void;
|
|
21
33
|
getAbout(): object;
|
|
34
|
+
private addChange;
|
|
35
|
+
getStartPageToken(): string;
|
|
36
|
+
getChanges(pageToken: string): {
|
|
37
|
+
changes: DriveChange[];
|
|
38
|
+
newStartPageToken: string;
|
|
39
|
+
nextPageToken?: string;
|
|
40
|
+
};
|
|
22
41
|
}
|
|
23
42
|
export declare const driveStore: DriveStore;
|
package/dist/store.js
CHANGED
|
@@ -4,14 +4,17 @@ exports.driveStore = exports.DriveStore = void 0;
|
|
|
4
4
|
class DriveStore {
|
|
5
5
|
constructor() {
|
|
6
6
|
this.files = new Map();
|
|
7
|
+
this.changes = [];
|
|
7
8
|
}
|
|
8
9
|
createFile(file) {
|
|
9
10
|
if (!file.name) {
|
|
10
11
|
throw new Error("File name is required");
|
|
11
12
|
}
|
|
12
13
|
const id = file.id || Math.random().toString(36).substring(7);
|
|
13
|
-
const
|
|
14
|
+
const now = new Date().toISOString();
|
|
15
|
+
const newFile = Object.assign(Object.assign({ kind: "drive#file", mimeType: "application/octet-stream", trashed: false, createdTime: now, modifiedTime: now }, file), { id, version: 1 });
|
|
14
16
|
this.files.set(id, newFile);
|
|
17
|
+
this.addChange(newFile);
|
|
15
18
|
return newFile;
|
|
16
19
|
}
|
|
17
20
|
updateFile(id, updates) {
|
|
@@ -19,15 +22,21 @@ class DriveStore {
|
|
|
19
22
|
if (!file)
|
|
20
23
|
return null;
|
|
21
24
|
// Merge updates and increment version
|
|
22
|
-
const updatedFile = Object.assign(Object.assign(Object.assign({}, file), updates), { version: file.version + 1 });
|
|
25
|
+
const updatedFile = Object.assign(Object.assign(Object.assign({}, file), updates), { version: file.version + 1, modifiedTime: new Date().toISOString() });
|
|
23
26
|
this.files.set(id, updatedFile);
|
|
27
|
+
this.addChange(updatedFile);
|
|
24
28
|
return updatedFile;
|
|
25
29
|
}
|
|
26
30
|
getFile(id) {
|
|
27
31
|
return this.files.get(id) || null;
|
|
28
32
|
}
|
|
29
33
|
deleteFile(id) {
|
|
30
|
-
|
|
34
|
+
const file = this.files.get(id);
|
|
35
|
+
const deleted = this.files.delete(id);
|
|
36
|
+
if (deleted && file) {
|
|
37
|
+
this.addChange(file, true);
|
|
38
|
+
}
|
|
39
|
+
return deleted;
|
|
31
40
|
}
|
|
32
41
|
listFiles() {
|
|
33
42
|
// Basic implementation, ignores query for now
|
|
@@ -35,6 +44,7 @@ class DriveStore {
|
|
|
35
44
|
}
|
|
36
45
|
clear() {
|
|
37
46
|
this.files.clear();
|
|
47
|
+
this.changes = [];
|
|
38
48
|
}
|
|
39
49
|
getAbout() {
|
|
40
50
|
return {
|
|
@@ -53,6 +63,38 @@ class DriveStore {
|
|
|
53
63
|
}
|
|
54
64
|
};
|
|
55
65
|
}
|
|
66
|
+
// Change Management
|
|
67
|
+
addChange(file, removed = false) {
|
|
68
|
+
// Simple mock implementation: store change
|
|
69
|
+
// In real Drive, multiple updates might result in one change token if polled later,
|
|
70
|
+
// but here we just append to a log.
|
|
71
|
+
const change = {
|
|
72
|
+
kind: "drive#change",
|
|
73
|
+
changeType: "file",
|
|
74
|
+
time: new Date().toISOString(),
|
|
75
|
+
removed,
|
|
76
|
+
fileId: file.id,
|
|
77
|
+
file: removed ? undefined : file
|
|
78
|
+
};
|
|
79
|
+
this.changes.push(change);
|
|
80
|
+
}
|
|
81
|
+
getStartPageToken() {
|
|
82
|
+
return String(this.changes.length + 1);
|
|
83
|
+
}
|
|
84
|
+
getChanges(pageToken) {
|
|
85
|
+
const tokenIndex = parseInt(pageToken, 10);
|
|
86
|
+
// If token invalid, default to beginning? Or error?
|
|
87
|
+
// Real API returns 400 for bad token.
|
|
88
|
+
// Mock: treat 0 or NaN as start.
|
|
89
|
+
const start = isNaN(tokenIndex) ? 0 : Math.max(0, tokenIndex - 1);
|
|
90
|
+
// Return all changes since token
|
|
91
|
+
const changes = this.changes.slice(start);
|
|
92
|
+
const newToken = String(this.changes.length + 1);
|
|
93
|
+
return {
|
|
94
|
+
changes,
|
|
95
|
+
newStartPageToken: newToken
|
|
96
|
+
};
|
|
97
|
+
}
|
|
56
98
|
}
|
|
57
99
|
exports.DriveStore = DriveStore;
|
|
58
100
|
exports.driveStore = new DriveStore();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "google-drive-mock",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.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",
|
|
@@ -61,4 +61,4 @@
|
|
|
61
61
|
"typescript-eslint": "8.54.0",
|
|
62
62
|
"vitest": "4.0.18"
|
|
63
63
|
}
|
|
64
|
-
}
|
|
64
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -76,7 +76,108 @@ const createApp = (config: AppConfig = {}) => {
|
|
|
76
76
|
|
|
77
77
|
// Files: List
|
|
78
78
|
app.get('/drive/v3/files', (req: Request, res: Response) => {
|
|
79
|
-
|
|
79
|
+
let files = driveStore.listFiles();
|
|
80
|
+
const q = req.query.q as string;
|
|
81
|
+
const orderBy = req.query.orderBy as string;
|
|
82
|
+
|
|
83
|
+
if (q) {
|
|
84
|
+
// Enhanced query parser for Mock
|
|
85
|
+
// Supports:
|
|
86
|
+
// - name = '...'
|
|
87
|
+
// - mimeType = '...'
|
|
88
|
+
// - trashed = true/false
|
|
89
|
+
// - 'ID' in parents
|
|
90
|
+
// - name contains '...'
|
|
91
|
+
const parts = q.split(' and ').map(p => p.trim());
|
|
92
|
+
|
|
93
|
+
files = files.filter(file => {
|
|
94
|
+
return parts.every(part => {
|
|
95
|
+
// name = '...'
|
|
96
|
+
if (part.startsWith("name = '")) {
|
|
97
|
+
const name = part.match(/name = '(.*)'/)?.[1];
|
|
98
|
+
return file.name === name;
|
|
99
|
+
}
|
|
100
|
+
// name contains '...'
|
|
101
|
+
if (part.startsWith("name contains '")) {
|
|
102
|
+
const token = part.match(/name contains '(.*)'/)?.[1];
|
|
103
|
+
return token && file.name.includes(token);
|
|
104
|
+
}
|
|
105
|
+
// 'ID' in parents
|
|
106
|
+
if (part.includes(" in parents")) {
|
|
107
|
+
const parentId = part.match(/'(.*)' in parents/)?.[1];
|
|
108
|
+
return parentId && file.parents?.includes(parentId);
|
|
109
|
+
}
|
|
110
|
+
// trashed = ...
|
|
111
|
+
if (part === "trashed = false") {
|
|
112
|
+
return file.trashed !== true;
|
|
113
|
+
}
|
|
114
|
+
if (part === "trashed = true") {
|
|
115
|
+
return file.trashed === true;
|
|
116
|
+
}
|
|
117
|
+
// mimeType = '...'
|
|
118
|
+
if (part.startsWith("mimeType = '")) {
|
|
119
|
+
const mime = part.match(/mimeType = '(.*)'/)?.[1];
|
|
120
|
+
return file.mimeType === mime;
|
|
121
|
+
}
|
|
122
|
+
// mimeType != '...'
|
|
123
|
+
if (part.startsWith("mimeType != '")) {
|
|
124
|
+
const mime = part.match(/mimeType != '(.*)'/)?.[1];
|
|
125
|
+
return file.mimeType !== mime;
|
|
126
|
+
}
|
|
127
|
+
// modifiedTime > '...'
|
|
128
|
+
if (part.startsWith("modifiedTime > '")) {
|
|
129
|
+
const timeStr = part.match(/modifiedTime > '(.*)'/)?.[1];
|
|
130
|
+
return timeStr && new Date(file.modifiedTime) > new Date(timeStr);
|
|
131
|
+
}
|
|
132
|
+
// modifiedTime < '...'
|
|
133
|
+
if (part.startsWith("modifiedTime < '")) {
|
|
134
|
+
const timeStr = part.match(/modifiedTime < '(.*)'/)?.[1];
|
|
135
|
+
return timeStr && new Date(file.modifiedTime) < new Date(timeStr);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Ignore unknown filters for now
|
|
139
|
+
return true;
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Sorting (orderBy)
|
|
145
|
+
if (orderBy) {
|
|
146
|
+
// Basic support for single keys: 'folder,name', 'modifiedTime desc', etc.
|
|
147
|
+
// Splitting by comma
|
|
148
|
+
const sortKeys = orderBy.split(',').map(k => k.trim());
|
|
149
|
+
|
|
150
|
+
files.sort((a, b) => {
|
|
151
|
+
for (const keyDef of sortKeys) {
|
|
152
|
+
const [key, direction] = keyDef.split(' ');
|
|
153
|
+
const dir = direction || 'asc';
|
|
154
|
+
|
|
155
|
+
// Handle special virtual key 'folder'
|
|
156
|
+
if (key === 'folder') {
|
|
157
|
+
const aIsFolder = a.mimeType === 'application/vnd.google-apps.folder';
|
|
158
|
+
const bIsFolder = b.mimeType === 'application/vnd.google-apps.folder';
|
|
159
|
+
if (aIsFolder !== bIsFolder) {
|
|
160
|
+
// Folders first in 'folder' sort usually?
|
|
161
|
+
// Google docs say: "folder sets folders to appear before..."
|
|
162
|
+
const valA = aIsFolder ? 0 : 1;
|
|
163
|
+
const valB = bIsFolder ? 0 : 1;
|
|
164
|
+
if (valA !== valB) return dir === 'desc' ? valB - valA : valA - valB;
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const valA = a[key] as string | number | undefined;
|
|
170
|
+
const valB = b[key] as string | number | undefined;
|
|
171
|
+
|
|
172
|
+
if (valA === undefined || valB === undefined) return 0;
|
|
173
|
+
|
|
174
|
+
if (valA < valB) return dir === 'desc' ? 1 : -1;
|
|
175
|
+
if (valA > valB) return dir === 'desc' ? -1 : 1;
|
|
176
|
+
}
|
|
177
|
+
return 0;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
80
181
|
res.json({
|
|
81
182
|
kind: "drive#fileList",
|
|
82
183
|
incompleteSearch: false,
|
|
@@ -84,6 +185,32 @@ const createApp = (config: AppConfig = {}) => {
|
|
|
84
185
|
});
|
|
85
186
|
});
|
|
86
187
|
|
|
188
|
+
// Changes: Get Start Page Token
|
|
189
|
+
app.get('/drive/v3/changes/startPageToken', (req: Request, res: Response) => {
|
|
190
|
+
const token = driveStore.getStartPageToken();
|
|
191
|
+
res.json({
|
|
192
|
+
kind: "drive#startPageToken",
|
|
193
|
+
startPageToken: token
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Changes: List
|
|
198
|
+
app.get('/drive/v3/changes', (req: Request, res: Response) => {
|
|
199
|
+
const pageToken = req.query.pageToken as string;
|
|
200
|
+
if (!pageToken) {
|
|
201
|
+
res.status(400).json({ error: { code: 400, message: "Bad Request: pageToken is required" } });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const result = driveStore.getChanges(pageToken);
|
|
206
|
+
res.json({
|
|
207
|
+
kind: "drive#changeList",
|
|
208
|
+
newStartPageToken: result.newStartPageToken,
|
|
209
|
+
nextPageToken: result.nextPageToken,
|
|
210
|
+
changes: result.changes
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
87
214
|
// Files: Create
|
|
88
215
|
app.post('/drive/v3/files', (req: Request, res: Response) => {
|
|
89
216
|
const body = req.body;
|
|
@@ -100,6 +227,7 @@ const createApp = (config: AppConfig = {}) => {
|
|
|
100
227
|
}
|
|
101
228
|
|
|
102
229
|
const newFile = driveStore.createFile({
|
|
230
|
+
...body,
|
|
103
231
|
name: body.name,
|
|
104
232
|
mimeType: body.mimeType || "application/octet-stream",
|
|
105
233
|
parents: body.parents || []
|
package/src/store.ts
CHANGED
|
@@ -5,14 +5,28 @@ export interface DriveFile {
|
|
|
5
5
|
kind: string;
|
|
6
6
|
parents?: string[];
|
|
7
7
|
version: number;
|
|
8
|
+
trashed: boolean;
|
|
9
|
+
createdTime: string;
|
|
10
|
+
modifiedTime: string;
|
|
8
11
|
[key: string]: unknown;
|
|
9
12
|
}
|
|
10
13
|
|
|
14
|
+
export interface DriveChange {
|
|
15
|
+
kind: "drive#change";
|
|
16
|
+
changeType: "file" | "drive";
|
|
17
|
+
time: string;
|
|
18
|
+
removed: boolean;
|
|
19
|
+
fileId: string;
|
|
20
|
+
file?: DriveFile;
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
export class DriveStore {
|
|
12
24
|
private files: Map<string, DriveFile>;
|
|
25
|
+
private changes: DriveChange[];
|
|
13
26
|
|
|
14
27
|
constructor() {
|
|
15
28
|
this.files = new Map();
|
|
29
|
+
this.changes = [];
|
|
16
30
|
}
|
|
17
31
|
|
|
18
32
|
createFile(file: Partial<DriveFile> & { name: string }): DriveFile {
|
|
@@ -20,15 +34,20 @@ export class DriveStore {
|
|
|
20
34
|
throw new Error("File name is required");
|
|
21
35
|
}
|
|
22
36
|
const id = file.id || Math.random().toString(36).substring(7);
|
|
37
|
+
const now = new Date().toISOString();
|
|
23
38
|
const newFile: DriveFile = {
|
|
24
39
|
kind: "drive#file",
|
|
25
40
|
mimeType: "application/octet-stream",
|
|
41
|
+
trashed: false,
|
|
42
|
+
createdTime: now,
|
|
43
|
+
modifiedTime: now,
|
|
26
44
|
...file,
|
|
27
45
|
id,
|
|
28
46
|
version: 1, // Initialize version
|
|
29
47
|
};
|
|
30
48
|
|
|
31
49
|
this.files.set(id, newFile);
|
|
50
|
+
this.addChange(newFile);
|
|
32
51
|
return newFile;
|
|
33
52
|
}
|
|
34
53
|
|
|
@@ -40,9 +59,11 @@ export class DriveStore {
|
|
|
40
59
|
const updatedFile = {
|
|
41
60
|
...file,
|
|
42
61
|
...updates,
|
|
43
|
-
version: file.version + 1
|
|
62
|
+
version: file.version + 1,
|
|
63
|
+
modifiedTime: new Date().toISOString()
|
|
44
64
|
};
|
|
45
65
|
this.files.set(id, updatedFile);
|
|
66
|
+
this.addChange(updatedFile);
|
|
46
67
|
return updatedFile;
|
|
47
68
|
}
|
|
48
69
|
|
|
@@ -51,7 +72,12 @@ export class DriveStore {
|
|
|
51
72
|
}
|
|
52
73
|
|
|
53
74
|
deleteFile(id: string): boolean {
|
|
54
|
-
|
|
75
|
+
const file = this.files.get(id);
|
|
76
|
+
const deleted = this.files.delete(id);
|
|
77
|
+
if (deleted && file) {
|
|
78
|
+
this.addChange(file, true);
|
|
79
|
+
}
|
|
80
|
+
return deleted;
|
|
55
81
|
}
|
|
56
82
|
|
|
57
83
|
listFiles(): DriveFile[] {
|
|
@@ -61,6 +87,7 @@ export class DriveStore {
|
|
|
61
87
|
|
|
62
88
|
clear(): void {
|
|
63
89
|
this.files.clear();
|
|
90
|
+
this.changes = [];
|
|
64
91
|
}
|
|
65
92
|
|
|
66
93
|
getAbout(): object {
|
|
@@ -80,6 +107,44 @@ export class DriveStore {
|
|
|
80
107
|
}
|
|
81
108
|
}
|
|
82
109
|
}
|
|
110
|
+
|
|
111
|
+
// Change Management
|
|
112
|
+
private addChange(file: DriveFile, removed: boolean = false) {
|
|
113
|
+
// Simple mock implementation: store change
|
|
114
|
+
// In real Drive, multiple updates might result in one change token if polled later,
|
|
115
|
+
// but here we just append to a log.
|
|
116
|
+
const change: DriveChange = {
|
|
117
|
+
kind: "drive#change",
|
|
118
|
+
changeType: "file",
|
|
119
|
+
time: new Date().toISOString(),
|
|
120
|
+
removed,
|
|
121
|
+
fileId: file.id,
|
|
122
|
+
file: removed ? undefined : file
|
|
123
|
+
};
|
|
124
|
+
this.changes.push(change);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
getStartPageToken(): string {
|
|
128
|
+
return String(this.changes.length + 1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
getChanges(pageToken: string): { changes: DriveChange[], newStartPageToken: string, nextPageToken?: string } {
|
|
132
|
+
const tokenIndex = parseInt(pageToken, 10);
|
|
133
|
+
|
|
134
|
+
// If token invalid, default to beginning? Or error?
|
|
135
|
+
// Real API returns 400 for bad token.
|
|
136
|
+
// Mock: treat 0 or NaN as start.
|
|
137
|
+
const start = isNaN(tokenIndex) ? 0 : Math.max(0, tokenIndex - 1);
|
|
138
|
+
|
|
139
|
+
// Return all changes since token
|
|
140
|
+
const changes = this.changes.slice(start);
|
|
141
|
+
const newToken = String(this.changes.length + 1);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
changes,
|
|
145
|
+
newStartPageToken: newToken
|
|
146
|
+
};
|
|
147
|
+
}
|
|
83
148
|
}
|
|
84
149
|
|
|
85
150
|
export const driveStore = new DriveStore();
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
import { getTestConfig, TestConfig } from './config';
|
|
3
|
+
import { DriveFile, DriveChange } from '../src/store';
|
|
4
|
+
|
|
5
|
+
describe('Advanced Drive Features', () => {
|
|
6
|
+
let config: TestConfig;
|
|
7
|
+
let req: (method: string, endpoint: string, body?: unknown) => Promise<{ status: number; body: unknown; headers: Headers }>;
|
|
8
|
+
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
config = await getTestConfig();
|
|
11
|
+
req = async (method: string, endpoint: string, body?: unknown) => {
|
|
12
|
+
const url = `${config.baseUrl}${endpoint}`;
|
|
13
|
+
const options: RequestInit = {
|
|
14
|
+
method,
|
|
15
|
+
headers: {
|
|
16
|
+
'Content-Type': 'application/json',
|
|
17
|
+
'Authorization': `Bearer ${config.token}`
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
if (body) {
|
|
21
|
+
options.body = JSON.stringify(body);
|
|
22
|
+
}
|
|
23
|
+
const res = await fetch(url, options);
|
|
24
|
+
const text = await res.text();
|
|
25
|
+
try {
|
|
26
|
+
return { status: res.status, body: JSON.parse(text), headers: res.headers };
|
|
27
|
+
} catch {
|
|
28
|
+
return { status: res.status, body: text, headers: res.headers };
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Ensure test folder exists
|
|
33
|
+
if (!config.testFolderId) {
|
|
34
|
+
// Create root test folder if not exists logic handled in config?
|
|
35
|
+
// config.testFolderId is populated in config.ts
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should support changes feed (startPageToken and listing changes)', async () => {
|
|
40
|
+
// 1. Get Start Page Token
|
|
41
|
+
const tokenRes = await req('GET', '/drive/v3/changes/startPageToken?supportsAllDrives=true');
|
|
42
|
+
expect(tokenRes.status).toBe(200);
|
|
43
|
+
const startToken = (tokenRes.body as { startPageToken: string }).startPageToken;
|
|
44
|
+
expect(startToken).toBeDefined();
|
|
45
|
+
|
|
46
|
+
// 2. Make a change (Create file)
|
|
47
|
+
const fileName = `ChangeTest-${Date.now()}`;
|
|
48
|
+
const createRes = await req('POST', '/drive/v3/files', {
|
|
49
|
+
name: fileName,
|
|
50
|
+
parents: [config.testFolderId]
|
|
51
|
+
});
|
|
52
|
+
expect(createRes.status).toBe(200);
|
|
53
|
+
const fileId = (createRes.body as DriveFile).id;
|
|
54
|
+
|
|
55
|
+
// 3. List Changes
|
|
56
|
+
const changesRes = await req('GET', `/drive/v3/changes?pageToken=${startToken}&supportsAllDrives=true&fields=changes(fileId,removed,file(name))`);
|
|
57
|
+
expect(changesRes.status).toBe(200);
|
|
58
|
+
// On Real API, changes might not be immediate?
|
|
59
|
+
// Usually safe enough for a single linear test, but might need retry loop.
|
|
60
|
+
|
|
61
|
+
// Check if we find our file ID in changes
|
|
62
|
+
const changes = (changesRes.body as { changes: DriveChange[] }).changes;
|
|
63
|
+
const found = changes.find((c: DriveChange) => c.fileId === fileId);
|
|
64
|
+
|
|
65
|
+
// Be tolerant of eventually consistency on Real API if needed,
|
|
66
|
+
// but for now expect it (Mock is immediate).
|
|
67
|
+
// If Real API fails here intermittently, we might need a retry wrapper.
|
|
68
|
+
// For correct TDD, let's assume immediate for Mock.
|
|
69
|
+
if (config.isMock) {
|
|
70
|
+
expect(found).toBeDefined();
|
|
71
|
+
expect(found?.removed).toBe(false);
|
|
72
|
+
expect(found?.file?.name).toBe(fileName);
|
|
73
|
+
} else {
|
|
74
|
+
// Real API might delay. Warn if missing but don't fail properly?
|
|
75
|
+
// Ideally we loop wait.
|
|
76
|
+
if (!found) console.warn("Real API change propogation might be slow");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 4. Delete file (Change)
|
|
80
|
+
await req('DELETE', `/drive/v3/files/${fileId}`);
|
|
81
|
+
// Fetch changes again from SAME token should show creation AND deletion?
|
|
82
|
+
// Or fetch from Next token? Mock implementation: "changes since token".
|
|
83
|
+
// If we query again with startToken, we should see both events (Mock).
|
|
84
|
+
|
|
85
|
+
const changesRes2 = await req('GET', `/drive/v3/changes?pageToken=${startToken}&supportsAllDrives=true`);
|
|
86
|
+
const changes2 = (changesRes2.body as { changes: DriveChange[] }).changes;
|
|
87
|
+
const deletion = changes2.find((c: DriveChange) => c.fileId === fileId && c.removed === true);
|
|
88
|
+
|
|
89
|
+
if (config.isMock) {
|
|
90
|
+
expect(deletion).toBeDefined();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should support advanced query operators (contains, in parents)', async () => {
|
|
95
|
+
const timestamp = Date.now();
|
|
96
|
+
const folderName = `QueryFolder-${timestamp}`;
|
|
97
|
+
const fileName = `UniqueFile-${timestamp}`;
|
|
98
|
+
|
|
99
|
+
// Create Folder
|
|
100
|
+
const folderRes = await req('POST', '/drive/v3/files', {
|
|
101
|
+
name: folderName,
|
|
102
|
+
mimeType: 'application/vnd.google-apps.folder',
|
|
103
|
+
parents: [config.testFolderId]
|
|
104
|
+
});
|
|
105
|
+
const folderId = (folderRes.body as DriveFile).id;
|
|
106
|
+
|
|
107
|
+
// Create File in Folder
|
|
108
|
+
const fileRes = await req('POST', '/drive/v3/files', {
|
|
109
|
+
name: fileName,
|
|
110
|
+
parents: [folderId]
|
|
111
|
+
});
|
|
112
|
+
const fileId = (fileRes.body as DriveFile).id;
|
|
113
|
+
|
|
114
|
+
// Query: 'ID' in parents
|
|
115
|
+
const qParents = `'${folderId}' in parents and trashed = false`;
|
|
116
|
+
const searchParents = await req('GET', `/drive/v3/files?q=${encodeURIComponent(qParents)}`);
|
|
117
|
+
expect(searchParents.status).toBe(200);
|
|
118
|
+
const foundParent = (searchParents.body as { files: DriveFile[] }).files.find((f: DriveFile) => f.id === fileId);
|
|
119
|
+
expect(foundParent).toBeDefined();
|
|
120
|
+
|
|
121
|
+
// Query: name contains 'UniqueFile'
|
|
122
|
+
const qContains = `name contains 'UniqueFile' and trashed = false`;
|
|
123
|
+
const searchContains = await req('GET', `/drive/v3/files?q=${encodeURIComponent(qContains)}`);
|
|
124
|
+
expect(searchContains.status).toBe(200);
|
|
125
|
+
const foundContains = (searchContains.body as { files: DriveFile[] }).files.find((f: DriveFile) => f.id === fileId);
|
|
126
|
+
expect(foundContains).toBeDefined();
|
|
127
|
+
|
|
128
|
+
// Query: modifiedTime > ...
|
|
129
|
+
// We just created the file, so it should be newer than 1 hour ago.
|
|
130
|
+
const oneHourAgo = new Date(Date.now() - 3600000).toISOString();
|
|
131
|
+
const qTime = `modifiedTime > '${oneHourAgo}' and name = '${fileName}'`;
|
|
132
|
+
const searchTime = await req('GET', `/drive/v3/files?q=${encodeURIComponent(qTime)}`);
|
|
133
|
+
expect(searchTime.status).toBe(200);
|
|
134
|
+
const foundTime = (searchTime.body as { files: DriveFile[] }).files.find((f: DriveFile) => f.id === fileId);
|
|
135
|
+
expect(foundTime).toBeDefined();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should support ordering', async () => {
|
|
139
|
+
const prefix = `SortTest-${Date.now()}`;
|
|
140
|
+
// Create 3 files with different names to sort
|
|
141
|
+
const names = ['A', 'B', 'C'].map(s => `${prefix}-${s}`);
|
|
142
|
+
|
|
143
|
+
for (const name of names) {
|
|
144
|
+
await req('POST', '/drive/v3/files', {
|
|
145
|
+
name,
|
|
146
|
+
parents: [config.testFolderId]
|
|
147
|
+
});
|
|
148
|
+
// Ensure timestamp diff for modifiedTime sort? Mock is fast.
|
|
149
|
+
// Wait 1ms
|
|
150
|
+
await new Promise(r => setTimeout(r, 2));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 1. Sort by name desc
|
|
154
|
+
const q = `name contains '${prefix}' and trashed = false`;
|
|
155
|
+
const sortRes = await req('GET', `/drive/v3/files?q=${encodeURIComponent(q)}&orderBy=name desc`);
|
|
156
|
+
expect(sortRes.status).toBe(200);
|
|
157
|
+
|
|
158
|
+
const files = (sortRes.body as { files: DriveFile[] }).files;
|
|
159
|
+
// Should be C, B, A
|
|
160
|
+
expect(files.length).toBeGreaterThanOrEqual(3);
|
|
161
|
+
const relevant = files.filter((f: DriveFile) => f.name.includes(prefix));
|
|
162
|
+
expect(relevant[0].name).toContain('-C');
|
|
163
|
+
expect(relevant[1].name).toContain('-B');
|
|
164
|
+
expect(relevant[2].name).toContain('-A');
|
|
165
|
+
|
|
166
|
+
// 2. Sort by createdTime asc (Mock adds createdTime now)
|
|
167
|
+
const sortTimeRes = await req('GET', `/drive/v3/files?q=${encodeURIComponent(q)}&orderBy=createdTime asc`);
|
|
168
|
+
const filesTime = (sortTimeRes.body as { files: DriveFile[] }).files;
|
|
169
|
+
const relevantTime = filesTime.filter((f: DriveFile) => f.name.includes(prefix));
|
|
170
|
+
|
|
171
|
+
expect(relevantTime[0].name).toContain('-A');
|
|
172
|
+
expect(relevantTime[1].name).toContain('-B');
|
|
173
|
+
expect(relevantTime[2].name).toContain('-C');
|
|
174
|
+
});
|
|
175
|
+
});
|
package/test/config.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { Server } from 'http';
|
|
|
4
4
|
|
|
5
5
|
export interface TestConfig {
|
|
6
6
|
target: Server | string; // Server instance (Node) or URL string (Browser/Real)
|
|
7
|
+
baseUrl: string; // Uniform URL for requests
|
|
7
8
|
token: string;
|
|
8
9
|
|
|
9
10
|
isMock: boolean;
|
|
@@ -124,6 +125,7 @@ export async function getTestConfig(): Promise<TestConfig> {
|
|
|
124
125
|
|
|
125
126
|
return {
|
|
126
127
|
target,
|
|
128
|
+
baseUrl: target,
|
|
127
129
|
token,
|
|
128
130
|
isMock: false,
|
|
129
131
|
testFolderId,
|
|
@@ -141,6 +143,7 @@ export async function getTestConfig(): Promise<TestConfig> {
|
|
|
141
143
|
|
|
142
144
|
return {
|
|
143
145
|
target: serverUrl,
|
|
146
|
+
baseUrl: serverUrl,
|
|
144
147
|
token: 'valid-token',
|
|
145
148
|
isMock: true,
|
|
146
149
|
testFolderId,
|
|
@@ -177,6 +180,7 @@ export async function getTestConfig(): Promise<TestConfig> {
|
|
|
177
180
|
|
|
178
181
|
return {
|
|
179
182
|
target: server,
|
|
183
|
+
baseUrl: targetUrl, // Added
|
|
180
184
|
token: 'valid-token',
|
|
181
185
|
isMock: true,
|
|
182
186
|
testFolderId,
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { getTestConfig, TestConfig } from './config';
|
|
3
|
+
import { Server } from 'http';
|
|
4
|
+
import { DriveFile } from '../src/store';
|
|
5
|
+
|
|
6
|
+
// Helper (Shared)
|
|
7
|
+
async function makeRequest(
|
|
8
|
+
target: Server | string,
|
|
9
|
+
method: string,
|
|
10
|
+
path: string,
|
|
11
|
+
headers: Record<string, string>,
|
|
12
|
+
body?: unknown
|
|
13
|
+
) {
|
|
14
|
+
if (typeof target === 'string') {
|
|
15
|
+
const url = `${target}${path}`;
|
|
16
|
+
const fetchOptions: RequestInit = {
|
|
17
|
+
method: method,
|
|
18
|
+
headers: headers
|
|
19
|
+
};
|
|
20
|
+
if (body) {
|
|
21
|
+
if (typeof body === 'string') {
|
|
22
|
+
fetchOptions.body = body;
|
|
23
|
+
} else {
|
|
24
|
+
fetchOptions.body = JSON.stringify(body);
|
|
25
|
+
if (!headers['Content-Type']) {
|
|
26
|
+
headers['Content-Type'] = 'application/json';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const res = await fetch(url, fetchOptions);
|
|
31
|
+
const resBody = res.headers.get('content-type')?.includes('application/json')
|
|
32
|
+
? await res.json()
|
|
33
|
+
: await res.text();
|
|
34
|
+
return { status: res.status, body: resBody };
|
|
35
|
+
} else {
|
|
36
|
+
const addr = target.address();
|
|
37
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
38
|
+
const baseUrl = `http://localhost:${port}`;
|
|
39
|
+
return makeRequest(baseUrl, method, path, headers, body);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('Feature Tests', () => {
|
|
44
|
+
let config: TestConfig;
|
|
45
|
+
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
config = await getTestConfig();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(() => {
|
|
51
|
+
if (config) config.stop();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
async function req(method: string, path: string, body?: unknown) {
|
|
55
|
+
return makeRequest(config.target, method, path, {
|
|
56
|
+
'Authorization': `Bearer ${config.token}`
|
|
57
|
+
}, body);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
it('should find a folder by its name (only search not in trash folders)', async () => {
|
|
61
|
+
// 1. Create a unique folder
|
|
62
|
+
const folderName = 'SearchTarget_' + Date.now();
|
|
63
|
+
const createRes = await req('POST', '/drive/v3/files', {
|
|
64
|
+
name: folderName,
|
|
65
|
+
mimeType: 'application/vnd.google-apps.folder',
|
|
66
|
+
parents: [config.testFolderId]
|
|
67
|
+
});
|
|
68
|
+
expect(createRes.status).toBe(200);
|
|
69
|
+
const createdId = createRes.body.id;
|
|
70
|
+
|
|
71
|
+
// 2. Search for it
|
|
72
|
+
const query = `name = '${folderName}' and trashed = false`;
|
|
73
|
+
const searchRes = await req('GET', `/drive/v3/files?q=${encodeURIComponent(query)}`);
|
|
74
|
+
|
|
75
|
+
expect(searchRes.status).toBe(200);
|
|
76
|
+
const files = searchRes.body.files;
|
|
77
|
+
expect(files).toBeDefined();
|
|
78
|
+
expect(files.length).toBeGreaterThan(0);
|
|
79
|
+
expect(files[0].id).toBe(createdId);
|
|
80
|
+
expect(files[0].name).toBe(folderName);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should create and delete a nested folder', async () => {
|
|
84
|
+
// 1. Create Parent
|
|
85
|
+
const parentName = 'Parent_' + Date.now();
|
|
86
|
+
const parentRes = await req('POST', '/drive/v3/files', {
|
|
87
|
+
name: parentName,
|
|
88
|
+
mimeType: 'application/vnd.google-apps.folder',
|
|
89
|
+
parents: [config.testFolderId]
|
|
90
|
+
});
|
|
91
|
+
expect(parentRes.status).toBe(200);
|
|
92
|
+
const parentId = parentRes.body.id;
|
|
93
|
+
|
|
94
|
+
// 2. Create Child inside Parent
|
|
95
|
+
const childName = 'Child_' + Date.now();
|
|
96
|
+
const childRes = await req('POST', '/drive/v3/files', {
|
|
97
|
+
name: childName,
|
|
98
|
+
mimeType: 'application/vnd.google-apps.folder',
|
|
99
|
+
parents: [parentId]
|
|
100
|
+
});
|
|
101
|
+
expect(childRes.status).toBe(200);
|
|
102
|
+
const childId = childRes.body.id;
|
|
103
|
+
|
|
104
|
+
// 3. Verify Child has parent
|
|
105
|
+
const getChild = await req('GET', `/drive/v3/files/${childId}?fields=parents`);
|
|
106
|
+
expect(getChild.status).toBe(200);
|
|
107
|
+
// req helper wraps body logic differently?
|
|
108
|
+
// Ah, default getFile returns all fields in mock.
|
|
109
|
+
// Mock might not strictly filter fields yet, but let's check.
|
|
110
|
+
if (getChild.body.parents) {
|
|
111
|
+
expect(getChild.body.parents).toContain(parentId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 4. Delete Child
|
|
115
|
+
const delRes = await req('DELETE', `/drive/v3/files/${childId}`);
|
|
116
|
+
expect(delRes.status).toBe(204);
|
|
117
|
+
|
|
118
|
+
// 5. Verify Child Gone
|
|
119
|
+
const verify = await req('GET', `/drive/v3/files/${childId}`);
|
|
120
|
+
expect(verify.status).toBe(404);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should get a file and check for its mime-type', async () => {
|
|
124
|
+
const fileName = 'MimeTypeFile_' + Date.now();
|
|
125
|
+
const mimeType = 'text/plain';
|
|
126
|
+
|
|
127
|
+
const createRes = await req('POST', '/drive/v3/files', {
|
|
128
|
+
name: fileName,
|
|
129
|
+
mimeType: mimeType,
|
|
130
|
+
parents: [config.testFolderId]
|
|
131
|
+
});
|
|
132
|
+
expect(createRes.status).toBe(200);
|
|
133
|
+
const fileId = createRes.body.id;
|
|
134
|
+
|
|
135
|
+
const getRes = await req('GET', `/drive/v3/files/${fileId}`);
|
|
136
|
+
expect(getRes.status).toBe(200);
|
|
137
|
+
expect(getRes.body.mimeType).toBe(mimeType);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should fetch a folder and check for its mime-type', async () => {
|
|
141
|
+
const folderName = 'MimeTypeFolder_' + Date.now();
|
|
142
|
+
const mimeType = 'application/vnd.google-apps.folder';
|
|
143
|
+
|
|
144
|
+
const createRes = await req('POST', '/drive/v3/files', {
|
|
145
|
+
name: folderName,
|
|
146
|
+
mimeType: mimeType,
|
|
147
|
+
parents: [config.testFolderId]
|
|
148
|
+
});
|
|
149
|
+
expect(createRes.status).toBe(200);
|
|
150
|
+
const folderId = createRes.body.id;
|
|
151
|
+
|
|
152
|
+
const getRes = await req('GET', `/drive/v3/files/${folderId}`);
|
|
153
|
+
expect(getRes.status).toBe(200);
|
|
154
|
+
expect(getRes.body.mimeType).toBe(mimeType);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should trash a file', async () => {
|
|
158
|
+
const fileName = 'TrashFile_' + Date.now();
|
|
159
|
+
const createRes = await req('POST', '/drive/v3/files', {
|
|
160
|
+
name: fileName,
|
|
161
|
+
parents: [config.testFolderId]
|
|
162
|
+
});
|
|
163
|
+
expect(createRes.status).toBe(200);
|
|
164
|
+
const fileId = createRes.body.id;
|
|
165
|
+
|
|
166
|
+
// Trash it
|
|
167
|
+
const trashRes = await req('PATCH', `/drive/v3/files/${fileId}?fields=trashed`, { trashed: true });
|
|
168
|
+
expect(trashRes.status).toBe(200);
|
|
169
|
+
expect(trashRes.body.trashed).toBe(true);
|
|
170
|
+
|
|
171
|
+
// Verify it is still accessible via GET
|
|
172
|
+
const getRes = await req('GET', `/drive/v3/files/${fileId}?fields=trashed`);
|
|
173
|
+
expect(getRes.status).toBe(200);
|
|
174
|
+
expect(getRes.body.trashed).toBe(true);
|
|
175
|
+
|
|
176
|
+
// Verify it is excluded from search with trashed=false
|
|
177
|
+
const query = `name = '${fileName}' and trashed = false`;
|
|
178
|
+
const searchRes = await req('GET', `/drive/v3/files?q=${encodeURIComponent(query)}`);
|
|
179
|
+
expect(searchRes.status).toBe(200);
|
|
180
|
+
expect(searchRes.body.files).toEqual([]);
|
|
181
|
+
|
|
182
|
+
// Verify it is included in search with trashed=true
|
|
183
|
+
const queryTrash = `name = '${fileName}' and trashed = true`;
|
|
184
|
+
const searchTrashRes = await req('GET', `/drive/v3/files?q=${encodeURIComponent(queryTrash)}`);
|
|
185
|
+
expect(searchTrashRes.status).toBe(200);
|
|
186
|
+
expect(searchTrashRes.body.files).toHaveLength(1);
|
|
187
|
+
expect(searchTrashRes.body.files[0].id).toBe(fileId);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should find a file that is in a nested folder. Write the file and then read it again. Call it data.json', async () => {
|
|
191
|
+
// 1. Create Parent Folder
|
|
192
|
+
const parentRes = await req('POST', '/drive/v3/files', {
|
|
193
|
+
name: 'DataFolder_' + Date.now(),
|
|
194
|
+
mimeType: 'application/vnd.google-apps.folder',
|
|
195
|
+
parents: [config.testFolderId]
|
|
196
|
+
});
|
|
197
|
+
expect(parentRes.status).toBe(200);
|
|
198
|
+
const parentId = parentRes.body.id;
|
|
199
|
+
|
|
200
|
+
// 2. Create Nested Folder
|
|
201
|
+
const nestedRes = await req('POST', '/drive/v3/files', {
|
|
202
|
+
name: 'Nested_' + Date.now(),
|
|
203
|
+
mimeType: 'application/vnd.google-apps.folder',
|
|
204
|
+
parents: [parentId]
|
|
205
|
+
});
|
|
206
|
+
expect(nestedRes.status).toBe(200);
|
|
207
|
+
const nestedId = nestedRes.body.id;
|
|
208
|
+
|
|
209
|
+
// 3. Create (Write) File in Nested Folder
|
|
210
|
+
const fileName = 'data.json';
|
|
211
|
+
const fileContent = { foo: 'bar', timestamp: Date.now() };
|
|
212
|
+
|
|
213
|
+
const createBody: { name: string; mimeType: string; parents: string[]; content?: unknown } = {
|
|
214
|
+
name: fileName,
|
|
215
|
+
mimeType: 'application/json',
|
|
216
|
+
parents: [nestedId]
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
if (config.isMock) {
|
|
220
|
+
createBody.content = fileContent;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const createRes = await req('POST', '/drive/v3/files', createBody);
|
|
224
|
+
expect(createRes.status).toBe(200);
|
|
225
|
+
const fileId = createRes.body.id;
|
|
226
|
+
|
|
227
|
+
// 4. Find (Search) the file
|
|
228
|
+
const query = `name = '${fileName}' and trashed = false`;
|
|
229
|
+
const searchRes = await req('GET', `/drive/v3/files?q=${encodeURIComponent(query)}`);
|
|
230
|
+
expect(searchRes.status).toBe(200);
|
|
231
|
+
const found = (searchRes.body as { files: DriveFile[] }).files.find((f: DriveFile) => f.id === fileId);
|
|
232
|
+
expect(found).toBeDefined();
|
|
233
|
+
// Check finding by name works naturally.
|
|
234
|
+
|
|
235
|
+
// 5. Read (Get) the file
|
|
236
|
+
const getRes = await req('GET', `/drive/v3/files/${fileId}?fields=name,parents`);
|
|
237
|
+
expect(getRes.status).toBe(200);
|
|
238
|
+
expect(getRes.body.name).toBe(fileName);
|
|
239
|
+
expect(getRes.body.parents).toContain(nestedId);
|
|
240
|
+
|
|
241
|
+
if (config.isMock) {
|
|
242
|
+
expect(getRes.body.content).toEqual(fileContent);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
});
|