fix(mindspace): repair OA drive JSON body parsing and trash paths

Express already parses JSON request bodies, so drive handlers must read req.body directly. Also fix nested trash item original-path parsing to match file-drop-sync behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-10 19:04:27 +08:00
parent 6d5eb15b1b
commit 97a3e89ff7
4 changed files with 166 additions and 3 deletions
+158
View File
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { createMindSpaceOaDriveService } from './drive-service.mjs';
function createMockRes() {
return {
statusCode: 200,
headers: {},
set(fields) {
Object.assign(this.headers, fields);
},
status(code) {
this.statusCode = code;
return this;
},
json(payload) {
this.payload = payload;
return this;
},
send(body) {
this.body = body;
return this;
},
write() {},
end() {},
};
}
function createJsonReq(userId, method, subPath, body) {
const payload = JSON.stringify(body ?? {});
return {
method,
path: `/mindspace/v1/oa/drive${subPath}`,
url: `/mindspace/v1/oa/drive${subPath}`,
headers: { 'content-type': 'application/json' },
currentUser: { id: userId },
body: body ?? {},
on() {},
};
}
async function callJson(service, userId, method, subPath, body) {
const req = createJsonReq(userId, method, subPath, body);
const res = createMockRes();
await service.handlers.handle(req, res);
return res;
}
async function callMultipart(service, userId, filename, content, prefix = '') {
const boundary = '----oa-drive-test';
const chunks = [
`--${boundary}\r\n`,
`Content-Disposition: form-data; name="prefix"\r\n\r\n`,
`${prefix}\r\n`,
`--${boundary}\r\n`,
`Content-Disposition: form-data; name="file"; filename="${filename}"\r\n`,
'Content-Type: application/octet-stream\r\n\r\n',
content,
`\r\n--${boundary}--\r\n`,
];
const buffer = Buffer.concat(chunks.map((part) => Buffer.from(part)));
const req = {
method: 'POST',
path: '/mindspace/v1/oa/drive/upload',
url: '/mindspace/v1/oa/drive/upload?onConflict=rename',
headers: { 'content-type': `multipart/form-data; boundary=${boundary}` },
currentUser: { id: userId },
body: buffer,
on() {},
};
const res = createMockRes();
await service.handlers.handle(req, res);
return res;
}
test('oa drive API parity with file-drop-sync core flows', async () => {
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'memind-oa-drive-api-'));
const userId = 'user-oa-drive-api';
const oaRoot = path.join(tmpRoot, 'MindSpace', userId, 'oa');
await fsp.mkdir(oaRoot, { recursive: true });
let runtime = null;
const mindSpace = {
async getQuota() {
return {
quotaBytes: 1024 * 1024 * 1024,
usedBytes: 0,
reservedBytes: 0,
availableBytes: 1024 * 1024 * 1024,
};
},
};
const syncCalls = [];
const service = createMindSpaceOaDriveService({
h5Root: tmpRoot,
getMindSpace: () => mindSpace,
getMindSpaceAssets: () => ({
async syncWorkspaceAssets(uid, options) {
syncCalls.push({ uid, options });
},
}),
});
runtime = service.getRuntime(userId);
try {
let res = await callMultipart(service, userId, 'hello.txt', 'hello');
assert.equal(res.statusCode, 200);
assert.deepEqual(res.payload.saved, ['hello.txt']);
res = await callJson(service, userId, 'GET', '/files');
assert.ok(res.payload.files.some((file) => file.name === 'hello.txt'));
res = await callJson(service, userId, 'POST', '/folders', { name: 'box', parent: '' });
assert.equal(res.statusCode, 200);
assert.equal(res.payload.path, 'box');
res = await callJson(service, userId, 'POST', '/move', {
items: ['hello.txt'],
dest: 'box',
});
assert.equal(res.statusCode, 200);
assert.equal(res.payload.moved[0].to, 'box/hello.txt');
res = await callJson(service, userId, 'POST', '/rename', {
from: 'box/hello.txt',
name: 'world.txt',
});
assert.equal(res.statusCode, 200);
res = await callJson(service, userId, 'POST', '/copy', {
items: ['box/world.txt'],
dest: '',
});
assert.equal(res.statusCode, 200);
assert.ok(res.payload.copied.some((item) => item.to === 'world.txt'));
res = await callJson(service, userId, 'POST', '/trash', { items: ['box/world.txt'] });
assert.equal(res.statusCode, 200);
assert.equal(res.payload.trashed.length, 1);
res = await callJson(service, userId, 'GET', '/trash');
assert.ok(res.payload.items.some((item) => item.original === 'box/world.txt'));
res = await callJson(service, userId, 'GET', '/info');
assert.equal(res.statusCode, 200);
assert.equal(res.payload.pageSize, 100);
assert.ok(res.payload.quota);
assert.ok(syncCalls.length >= 5);
} finally {
runtime?.dispose();
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
+3
View File
@@ -68,6 +68,9 @@ async function readBodyBuffer(req, limit) {
}
async function readJsonBody(req, limit = 1024 * 1024) {
if (req.body && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) {
return req.body;
}
const raw = (await readBodyBuffer(req, limit)).toString('utf8').trim();
if (!raw) return {};
return JSON.parse(raw);
@@ -39,7 +39,6 @@ test('oa drive upload/list/rename/trash against user oa root', async () => {
getMindSpaceAssets: () => assets,
});
const runtime = service.getRuntime(userId);
await runtime.bootstrap();
const req = {
method: 'POST',
+5 -2
View File
@@ -35,10 +35,13 @@ export async function listTrash(ctx) {
for (const entry of entries) {
const full = path.join(trashRoot(ctx), entry.name);
const stat = await fsp.stat(full);
const match = entry.name.match(/^(.+)__(.+)$/);
const separator = entry.name.indexOf('__');
const original = separator >= 0
? entry.name.slice(separator + 2).replace(/__/g, '/')
: entry.name;
items.push({
id: entry.name,
original: match ? match[2].replace(/__/g, '/') : entry.name,
original,
trashedAt: stat.mtimeMs,
isDirectory: stat.isDirectory(),
size: stat.isDirectory() ? null : stat.size,