import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { createWorkspaceAssetSync, listWorkspaceZoneFiles, shouldSyncWorkspaceFilename, } from './mindspace-workspace-sync.mjs'; import { resolveUserWorkspaceRoot } from './user-space.mjs'; test('shouldSyncWorkspaceFilename skips helper files', () => { assert.equal(shouldSyncWorkspaceFilename('端午感怀.docx'), true); assert.equal(shouldSyncWorkspaceFilename('暑假实习报告/暑假生活实习报告.docx'), true); assert.equal(shouldSyncWorkspaceFilename('诗歌散文/夏日的诗篇.md'), true); assert.equal(shouldSyncWorkspaceFilename('sales-manager/README.md'), false); assert.equal(shouldSyncWorkspaceFilename('index.html'), false); assert.equal(shouldSyncWorkspaceFilename('note.thumbnail.svg'), false); assert.equal(shouldSyncWorkspaceFilename('../escape.docx'), false); }); test('listWorkspaceZoneFiles returns supported top-level and nested zone files', async () => { const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-')); const workspace = resolveUserWorkspaceRoot(h5Root, { id: 'user-1', username: 'john' }); await fs.mkdir(path.join(workspace, 'oa', '暑假实习报告'), { recursive: true }); await fs.mkdir(path.join(workspace, 'oa', '诗歌散文'), { recursive: true }); await fs.writeFile(path.join(workspace, 'oa', 'report.csv'), 'a,b\n1,2\n'); await fs.writeFile(path.join(workspace, 'oa', 'index.html'), ''); await fs.writeFile(path.join(workspace, 'oa', 'note.txt'), 'hello'); await fs.writeFile(path.join(workspace, 'oa', '诗歌散文', '夏日的诗篇.md'), '# 夏日的诗篇\n'); await fs.writeFile( path.join(workspace, 'oa', '暑假实习报告', '暑假生活实习报告.docx'), 'fake docx', ); const files = await listWorkspaceZoneFiles(workspace, 'oa'); assert.deepEqual( files.map((item) => item.filename).sort(), ['note.txt', 'report.csv', '暑假实习报告/暑假生活实习报告.docx', '诗歌散文/夏日的诗篇.md'].sort(), ); }); test('syncUserWorkspace imports new workspace files into asset library', async () => { const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-')); const storageRoot = path.join(h5Root, 'data', 'mindspace'); const workspace = resolveUserWorkspaceRoot(h5Root, { id: 'user-1', username: 'john' }); await fs.mkdir(path.join(workspace, 'oa'), { recursive: true }); await fs.writeFile(path.join(workspace, 'oa', 'memo.txt'), 'hello workspace\n'); const state = { users: [{ id: 'user-1', username: 'john' }], categories: [ { id: 'cat-1', user_id: 'user-1', space_id: 'space-1', category_code: 'oa' }, ], spaces: [ { id: 'space-1', user_id: 'user-1', quota_bytes: 5 * 1024 * 1024, used_bytes: 0, reserved_bytes: 0, status: 'active', }, ], assets: [], versions: [], }; let nextId = 0; const pool = { async query(sql, params = []) { if (sql.includes('SELECT username FROM h5_users')) { return [[{ username: 'john' }]]; } if (sql.includes('FROM h5_space_categories c') && sql.includes('category_code = ?')) { const category = state.categories.find( (item) => item.user_id === params[0] && item.category_code === params[1], ); return [category ? [category] : []]; } if (sql.includes('FROM h5_assets')) { const assets = state.assets.filter((item) => { if (item.user_id !== params[0] || item.category_id !== params[1]) return false; if (sql.includes("status <> 'deleted'") && item.status === 'deleted') return false; if (sql.includes("status = 'deleted'") && item.status !== 'deleted') return false; if (sql.includes("source_type = 'workspace'") && item.source_type !== 'workspace') { return false; } return true; }); return [assets]; } return [[]]; }, async getConnection() { return { async beginTransaction() {}, async commit() {}, async rollback() {}, release() {}, async query(sql, params = []) { if (sql.includes('FROM h5_user_spaces') && sql.includes('FOR UPDATE')) { const space = state.spaces.find( (item) => item.id === params[0] && item.user_id === params[1], ); return [space ? [space] : []]; } if (sql.includes('INSERT INTO h5_assets')) { state.assets.push({ id: params[0], user_id: params[1], category_id: params[3], original_filename: params[6], checksum: params[10], size_bytes: params[9], current_version_id: params[8], status: params[13], source_type: 'workspace', }); return [[]]; } if (sql.includes('INSERT INTO h5_asset_versions')) { state.versions.push({ id: params[0], asset_id: params[1], storage_key: params[2], scan_status: params[7], }); return [[]]; } if (sql.includes('used_bytes = used_bytes +')) { state.spaces[0].used_bytes += params[0]; return [[]]; } if (sql.includes('COALESCE(MAX(version_no)')) { return [[{ max_version: 0 }]]; } return pool.query(sql, params); }, }; }, }; const packageCalls = []; const conversationPackageRegistry = { async ensurePackage(input) { packageCalls.push(['ensurePackage', input]); return { id: 'cp-session-1' }; }, async recordArtifact(input) { packageCalls.push(['recordArtifact', input]); return input; }, async writeManifestForSession(input) { packageCalls.push(['writeManifestForSession', input]); return input; }, }; const sync = createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes: 1024 * 1024, idFactory: () => `id-${++nextId}`, conversationPackageRegistry, }); const result = await sync.syncUserWorkspace('user-1', { categoryCode: 'oa', sourceSessionId: 'session-1', sourceMessageId: 'message-1', title: 'Workspace chat', }); assert.equal(result.imported, 1); assert.equal(state.assets.length, 1); assert.equal(state.assets[0].original_filename, 'memo.txt'); assert.ok(state.versions.length === 1); assert.match(state.versions[0].storage_key, /^workspace:\/\/user-1\/oa\/memo\.txt$/); assert.equal(await fs.readFile(path.join(workspace, 'oa', 'memo.txt'), 'utf8'), 'hello workspace\n'); assert.deepEqual(packageCalls[0], [ 'ensurePackage', { userId: 'user-1', sessionId: 'session-1', title: 'Workspace chat', now: packageCalls[0][1].now, }, ]); const artifactCall = packageCalls.find(([name]) => name === 'recordArtifact')?.[1]; assert.equal(artifactCall.packageId, 'cp-session-1'); assert.equal(artifactCall.artifactKind, 'generated_file'); assert.equal(artifactCall.role, 'assistant'); assert.equal(artifactCall.assetId, 'id-1'); assert.equal(artifactCall.messageId, 'message-1'); assert.equal(artifactCall.displayName, 'memo.txt'); assert.equal(artifactCall.mimeType, 'text/plain'); assert.equal(artifactCall.storageKey, 'workspace://user-1/oa/memo.txt'); assert.equal(artifactCall.canonicalUrl, '/api/mindspace/v1/assets/id-1/download'); assert.deepEqual(packageCalls.at(-1), [ 'writeManifestForSession', { userId: 'user-1', sessionId: 'session-1' }, ]); }); test('syncUserWorkspace imports nested workspace files into asset library', async () => { const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-nested-')); const storageRoot = path.join(h5Root, 'data', 'mindspace'); const workspace = resolveUserWorkspaceRoot(h5Root, { id: 'user-1', username: 'john' }); await fs.mkdir(path.join(workspace, 'oa', '暑假实习报告'), { recursive: true }); await fs.writeFile(path.join(workspace, 'oa', '暑假实习报告', 'report.csv'), 'a,b\n1,2\n'); const state = { categories: [ { id: 'cat-1', user_id: 'user-1', space_id: 'space-1', category_code: 'oa' }, ], spaces: [ { id: 'space-1', user_id: 'user-1', quota_bytes: 5 * 1024 * 1024, used_bytes: 0, reserved_bytes: 0, status: 'active', }, ], assets: [], versions: [], }; let nextId = 0; const pool = { async query(sql, params = []) { if (sql.includes('FROM h5_space_categories c') && sql.includes('category_code = ?')) { const category = state.categories.find( (item) => item.user_id === params[0] && item.category_code === params[1], ); return [category ? [category] : []]; } if (sql.includes('FROM h5_assets')) { const assets = state.assets.filter((item) => { if (item.user_id !== params[0] || item.category_id !== params[1]) return false; if (sql.includes("status <> 'deleted'") && item.status === 'deleted') return false; if (sql.includes("status = 'deleted'") && item.status !== 'deleted') return false; if (sql.includes("source_type = 'workspace'") && item.source_type !== 'workspace') { return false; } return true; }); return [assets]; } return [[]]; }, async getConnection() { return { async beginTransaction() {}, async commit() {}, async rollback() {}, release() {}, async query(sql, params = []) { if (sql.includes('FROM h5_user_spaces') && sql.includes('FOR UPDATE')) { const space = state.spaces.find( (item) => item.id === params[0] && item.user_id === params[1], ); return [space ? [space] : []]; } if (sql.includes('INSERT INTO h5_assets')) { state.assets.push({ id: params[0], user_id: params[1], category_id: params[3], original_filename: params[6], checksum: params[10], size_bytes: params[9], current_version_id: params[8], status: params[13], source_type: 'workspace', }); return [[]]; } if (sql.includes('INSERT INTO h5_asset_versions')) { state.versions.push({ id: params[0], asset_id: params[1], storage_key: params[2], scan_status: params[7], }); return [[]]; } if (sql.includes('used_bytes = used_bytes +')) { state.spaces[0].used_bytes += params[0]; return [[]]; } if (sql.includes('COALESCE(MAX(version_no)')) { return [[{ max_version: 0 }]]; } return pool.query(sql, params); }, }; }, }; const sync = createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes: 1024 * 1024, idFactory: () => `id-${++nextId}`, }); const result = await sync.syncUserWorkspace('user-1', { categoryCode: 'oa' }); assert.equal(result.imported, 1); assert.equal(state.assets[0].original_filename, '暑假实习报告/report.csv'); });