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:
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -68,6 +68,9 @@ async function readBodyBuffer(req, limit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function readJsonBody(req, limit = 1024 * 1024) {
|
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();
|
const raw = (await readBodyBuffer(req, limit)).toString('utf8').trim();
|
||||||
if (!raw) return {};
|
if (!raw) return {};
|
||||||
return JSON.parse(raw);
|
return JSON.parse(raw);
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ test('oa drive upload/list/rename/trash against user oa root', async () => {
|
|||||||
getMindSpaceAssets: () => assets,
|
getMindSpaceAssets: () => assets,
|
||||||
});
|
});
|
||||||
const runtime = service.getRuntime(userId);
|
const runtime = service.getRuntime(userId);
|
||||||
await runtime.bootstrap();
|
|
||||||
|
|
||||||
const req = {
|
const req = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -35,10 +35,13 @@ export async function listTrash(ctx) {
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const full = path.join(trashRoot(ctx), entry.name);
|
const full = path.join(trashRoot(ctx), entry.name);
|
||||||
const stat = await fsp.stat(full);
|
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({
|
items.push({
|
||||||
id: entry.name,
|
id: entry.name,
|
||||||
original: match ? match[2].replace(/__/g, '/') : entry.name,
|
original,
|
||||||
trashedAt: stat.mtimeMs,
|
trashedAt: stat.mtimeMs,
|
||||||
isDirectory: stat.isDirectory(),
|
isDirectory: stat.isDirectory(),
|
||||||
size: stat.isDirectory() ? null : stat.size,
|
size: stat.isDirectory() ? null : stat.size,
|
||||||
|
|||||||
Reference in New Issue
Block a user