diff --git a/mindspace-oa-drive/drive-api.test.mjs b/mindspace-oa-drive/drive-api.test.mjs new file mode 100644 index 0000000..38d51f2 --- /dev/null +++ b/mindspace-oa-drive/drive-api.test.mjs @@ -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 }); + } +}); diff --git a/mindspace-oa-drive/drive-handlers.mjs b/mindspace-oa-drive/drive-handlers.mjs index 5c00ea7..530fa78 100644 --- a/mindspace-oa-drive/drive-handlers.mjs +++ b/mindspace-oa-drive/drive-handlers.mjs @@ -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); diff --git a/mindspace-oa-drive/drive-service.test.mjs b/mindspace-oa-drive/drive-service.test.mjs index f5eb659..19d7bcd 100644 --- a/mindspace-oa-drive/drive-service.test.mjs +++ b/mindspace-oa-drive/drive-service.test.mjs @@ -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', diff --git a/mindspace-oa-drive/lib/trash.mjs b/mindspace-oa-drive/lib/trash.mjs index 11b8616..9adaeb9 100644 --- a/mindspace-oa-drive/lib/trash.mjs +++ b/mindspace-oa-drive/lib/trash.mjs @@ -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,