Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
645 lines
23 KiB
JavaScript
645 lines
23 KiB
JavaScript
/**
|
|
* Export/Import Round-Trip Tests - TDD Approach
|
|
* ADR-020: Export/Import — Quick Backup & Restore
|
|
*
|
|
* 🔴 RED → 🟢 GREEN → 🔵 REFACTOR
|
|
*
|
|
* Test Levels:
|
|
* - Level 1: Table Export/Import
|
|
* - Level 2: Project Export/Import
|
|
* - Level 3: Space Export/Import
|
|
* - Access Control Tests
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { dbAll, dbGet, dbRun, destroyAdapter, resetAdapter, safeJsonParse, toBool } from '../../database/connection.js';
|
|
// Services to test (will be created)
|
|
import { ExportService } from '../ExportService.js';
|
|
import { ImportService } from '../ImportService.js';
|
|
|
|
// ============================================================
|
|
// Test Helpers
|
|
// ============================================================
|
|
|
|
async function createTestUser(options = {}) {
|
|
const uniqueEmail = `test-export-${Date.now()}-${Math.random().toString(36).substring(7)}@hltrn.cc`;
|
|
const result = await dbRun(
|
|
'INSERT INTO users (email, password_hash, name, encryption_key_encrypted, email_verified, role) VALUES (?, ?, ?, ?, ?, ?)',
|
|
[uniqueEmail, 'hash', options.name || 'Test User', 'encrypted_key', 1, options.role || 'user']
|
|
);
|
|
return result.lastInsertRowid;
|
|
}
|
|
|
|
async function createTestSpace(ownerId, options = {}) {
|
|
const result = await dbRun(
|
|
'INSERT INTO spaces (owner_id, name, type, description) VALUES (?, ?, ?, ?)',
|
|
[ownerId, options.name || 'Test Space', options.type || 'business', options.description || 'Test space for export']
|
|
);
|
|
return { id: result.lastInsertRowid, owner_id: ownerId, name: options.name || 'Test Space' };
|
|
}
|
|
|
|
async function createTestProject(spaceId, ownerId, options = {}) {
|
|
const result = await dbRun(
|
|
'INSERT INTO projects (space_id, owner_id, name, description, icon, type) VALUES (?, ?, ?, ?, ?, ?)',
|
|
[spaceId, ownerId, options.name || 'Test Project', options.description || '', options.icon || '📁', options.type || 'business']
|
|
);
|
|
return { id: result.lastInsertRowid, space_id: spaceId, name: options.name || 'Test Project' };
|
|
}
|
|
|
|
async function createTestTable(projectId, options = {}) {
|
|
const result = await dbRun(
|
|
'INSERT INTO universal_tables (project_id, name, description, icon, is_system) VALUES (?, ?, ?, ?, ?)',
|
|
[projectId, options.name || 'Test Table', options.description || '', options.icon || '📊', toBool(false)]
|
|
);
|
|
return { id: result.lastInsertRowid, project_id: projectId, name: options.name || 'Test Table' };
|
|
}
|
|
|
|
async function createTestColumn(tableId, options = {}) {
|
|
const result = await dbRun(`
|
|
INSERT INTO table_columns (
|
|
table_id, column_name, display_name, type, config,
|
|
is_required, is_visible, order_index
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, [
|
|
tableId,
|
|
options.name || 'test_column',
|
|
options.displayName || options.name || 'Test Column',
|
|
options.type || 'text',
|
|
JSON.stringify(options.config || {}),
|
|
toBool(options.isRequired || false),
|
|
toBool(options.isVisible !== false),
|
|
options.orderIndex || 0
|
|
]);
|
|
return {
|
|
id: result.lastInsertRowid,
|
|
table_id: tableId,
|
|
name: options.name || 'test_column',
|
|
column_type: options.type || 'text'
|
|
};
|
|
}
|
|
|
|
function generateBaseId() {
|
|
return 'base_' + Date.now() + '_' + Math.random().toString(36).substring(2, 8);
|
|
}
|
|
|
|
async function createTestRow(tableId, data, userId = null) {
|
|
const baseId = generateBaseId();
|
|
const result = await dbRun(`
|
|
INSERT INTO table_rows (table_id, base_id, data, created_by)
|
|
VALUES (?, ?, ?, ?)
|
|
`, [tableId, baseId, JSON.stringify(data), userId]);
|
|
return { id: result.lastInsertRowid, table_id: tableId, data };
|
|
}
|
|
|
|
async function createTestDocument(projectId, options = {}) {
|
|
try {
|
|
const result = await dbRun(`
|
|
INSERT INTO documents (project_id, title, content, status)
|
|
VALUES (?, ?, ?, ?)
|
|
`, [
|
|
projectId,
|
|
options.title || 'Test Document',
|
|
JSON.stringify(options.content || { blocks: [] }),
|
|
options.status || 'published'
|
|
]);
|
|
return { id: result.lastInsertRowid, project_id: projectId, title: options.title || 'Test Document' };
|
|
} catch (e) {
|
|
// Documents table might not exist in test DB
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function createTestTableWithData(projectId, spec) {
|
|
// Create table
|
|
const table = await createTestTable(projectId, { name: spec.name });
|
|
|
|
// Create columns
|
|
for (let i = 0; i < spec.columns.length; i++) {
|
|
const colSpec = spec.columns[i];
|
|
await createTestColumn(table.id, {
|
|
name: colSpec.name,
|
|
displayName: colSpec.displayName || colSpec.name,
|
|
type: colSpec.type,
|
|
config: colSpec.config || {},
|
|
orderIndex: i
|
|
});
|
|
}
|
|
|
|
// Create rows
|
|
if (spec.rows) {
|
|
for (const rowData of spec.rows) {
|
|
await createTestRow(table.id, rowData);
|
|
}
|
|
}
|
|
|
|
return table;
|
|
}
|
|
|
|
async function getTableRows(tableId) {
|
|
const rows = await dbAll('SELECT * FROM table_rows WHERE table_id = ? ORDER BY id', [tableId]);
|
|
return rows.map(r => ({
|
|
id: r.id,
|
|
...safeJsonParse(r.data)
|
|
}));
|
|
}
|
|
|
|
async function getTableColumns(tableId) {
|
|
return await dbAll('SELECT * FROM table_columns WHERE table_id = ? ORDER BY order_index', [tableId]);
|
|
}
|
|
|
|
async function cleanupTestData() {
|
|
// Clean in reverse order of dependencies
|
|
await dbRun('DELETE FROM table_rows WHERE table_id IN (SELECT id FROM universal_tables WHERE name LIKE ?)', ['%Test%']);
|
|
await dbRun('DELETE FROM table_columns WHERE table_id IN (SELECT id FROM universal_tables WHERE name LIKE ?)', ['%Test%']);
|
|
await dbRun('DELETE FROM universal_tables WHERE name LIKE ?', ['%Test%']);
|
|
await dbRun('DELETE FROM documents WHERE title LIKE ?', ['%Test%']);
|
|
await dbRun('DELETE FROM projects WHERE name LIKE ?', ['%Test%']);
|
|
await dbRun('DELETE FROM spaces WHERE name LIKE ?', ['%Test%']);
|
|
await dbRun('DELETE FROM users WHERE email LIKE ?', ['%test-export%']);
|
|
}
|
|
|
|
// ============================================================
|
|
// Test Suite
|
|
// ============================================================
|
|
|
|
describe('Export/Import Round-Trip', () => {
|
|
|
|
beforeEach(async () => {
|
|
process.env.TEST_MODE = 'true';
|
|
process.env.SKIP_DEV_USER = 'true';
|
|
await resetAdapter();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
try {
|
|
await cleanupTestData();
|
|
} catch (e) {
|
|
// Ignore cleanup errors
|
|
}
|
|
await destroyAdapter();
|
|
});
|
|
|
|
// =========================================================================
|
|
// LEVEL 1: TABLE ROUND-TRIP
|
|
// =========================================================================
|
|
|
|
describe('Table Round-Trip', () => {
|
|
let testSpace;
|
|
let testProject;
|
|
let testTable;
|
|
let userId;
|
|
|
|
beforeEach(async () => {
|
|
userId = await createTestUser();
|
|
testSpace = await createTestSpace(userId);
|
|
testProject = await createTestProject(testSpace.id, userId);
|
|
testTable = await createTestTableWithData(testProject.id, {
|
|
name: 'customers',
|
|
columns: [
|
|
{ name: 'name', type: 'text' },
|
|
{ name: 'email', type: 'email' },
|
|
{ name: 'status', type: 'select' },
|
|
{ name: 'api_key', type: 'apiKey' },
|
|
{ name: 'password', type: 'password' }
|
|
],
|
|
rows: [
|
|
{ name: 'John', email: 'john@test.com', status: 'active', api_key: 'key123', password: 'hash123' },
|
|
{ name: 'Jane', email: 'jane@test.com', status: 'pending', api_key: 'key456', password: 'hash456' }
|
|
]
|
|
});
|
|
});
|
|
|
|
it('exports table with FULL mode and imports identical copy', async () => {
|
|
// 1. Export
|
|
const exported = await ExportService.exportTable(testTable.id, { mode: 'full' });
|
|
|
|
// 2. Verify export structure
|
|
expect(exported.type).toBe('table');
|
|
expect(exported.table.name).toBe('customers');
|
|
expect(exported.columns).toHaveLength(5);
|
|
expect(exported.rows).toHaveLength(2);
|
|
expect(exported.meta.exported_at).toBeDefined();
|
|
expect(exported.meta.mode).toBe('full');
|
|
expect(exported.meta.godcrm_version).toBeDefined();
|
|
|
|
// 3. Import to same project as new table
|
|
const imported = await ImportService.importTable(testProject.id, exported, {
|
|
mode: 'create',
|
|
newName: 'customers_copy'
|
|
});
|
|
|
|
// 4. Verify imported table matches original
|
|
expect(imported.tableId).toBeDefined();
|
|
expect(imported.rowsImported).toBe(2);
|
|
expect(imported.columnsCreated).toBe(5);
|
|
|
|
// 5. Deep comparison
|
|
const originalRows = await getTableRows(testTable.id);
|
|
const importedRows = await getTableRows(imported.tableId);
|
|
|
|
expect(importedRows).toHaveLength(originalRows.length);
|
|
expect(importedRows[0].name).toBe(originalRows[0].name);
|
|
expect(importedRows[0].email).toBe(originalRows[0].email);
|
|
expect(importedRows[0].api_key).toBe(originalRows[0].api_key); // Full mode = includes sensitive
|
|
});
|
|
|
|
it('exports table with SCHEMA_ONLY mode (no rows)', async () => {
|
|
const exported = await ExportService.exportTable(testTable.id, { mode: 'schema_only' });
|
|
|
|
expect(exported.columns).toHaveLength(5);
|
|
expect(exported.rows).toHaveLength(0);
|
|
expect(exported.meta.mode).toBe('schema_only');
|
|
|
|
const imported = await ImportService.importTable(testProject.id, exported, {
|
|
mode: 'create',
|
|
newName: 'empty_customers'
|
|
});
|
|
|
|
expect(imported.rowsImported).toBe(0);
|
|
expect(imported.columnsCreated).toBe(5);
|
|
});
|
|
|
|
it('exports table with SANITIZED mode (clears sensitive columns)', async () => {
|
|
const exported = await ExportService.exportTable(testTable.id, { mode: 'sanitized' });
|
|
|
|
// Sensitive columns should have null values
|
|
expect(exported.rows[0].api_key).toBeNull();
|
|
expect(exported.rows[0].password).toBeNull();
|
|
|
|
// Non-sensitive columns should be intact
|
|
expect(exported.rows[0].name).toBe('John');
|
|
expect(exported.rows[0].email).toBe('john@test.com');
|
|
|
|
// Meta should indicate sanitized columns
|
|
expect(exported.meta.sanitizedColumns).toContain('api_key');
|
|
expect(exported.meta.sanitizedColumns).toContain('password');
|
|
});
|
|
|
|
it('detects sensitive columns by type', async () => {
|
|
const sensitiveInfo = await ExportService.detectSensitiveColumns(testTable.id);
|
|
|
|
expect(sensitiveInfo.hasSensitive).toBe(true);
|
|
expect(sensitiveInfo.columns).toContainEqual(expect.objectContaining({
|
|
name: 'api_key',
|
|
type: 'apiKey',
|
|
reason: 'Column type is sensitive'
|
|
}));
|
|
expect(sensitiveInfo.columns).toContainEqual(expect.objectContaining({
|
|
name: 'password',
|
|
type: 'password',
|
|
reason: 'Column type is sensitive'
|
|
}));
|
|
});
|
|
|
|
it('detects sensitive columns by name pattern', async () => {
|
|
// Create table with sensitive-looking column names
|
|
const tableWithPatterns = await createTestTableWithData(testProject.id, {
|
|
name: 'credentials_test',
|
|
columns: [
|
|
{ name: 'user_password', type: 'text' },
|
|
{ name: 'secret_token', type: 'text' },
|
|
{ name: 'private_key', type: 'text' },
|
|
{ name: 'description', type: 'text' }
|
|
],
|
|
rows: [{ user_password: 'pwd', secret_token: 'tok', private_key: 'key', description: 'test' }]
|
|
});
|
|
|
|
const sensitiveInfo = await ExportService.detectSensitiveColumns(tableWithPatterns.id);
|
|
|
|
expect(sensitiveInfo.hasSensitive).toBe(true);
|
|
expect(sensitiveInfo.columns.map(c => c.name)).toContain('user_password');
|
|
expect(sensitiveInfo.columns.map(c => c.name)).toContain('secret_token');
|
|
expect(sensitiveInfo.columns.map(c => c.name)).toContain('private_key');
|
|
expect(sensitiveInfo.columns.map(c => c.name)).not.toContain('description');
|
|
});
|
|
|
|
it('detects no sensitive columns in clean table', async () => {
|
|
const cleanTable = await createTestTableWithData(testProject.id, {
|
|
name: 'clean_table_test',
|
|
columns: [
|
|
{ name: 'name', type: 'text' },
|
|
{ name: 'email', type: 'email' },
|
|
{ name: 'notes', type: 'text' }
|
|
],
|
|
rows: []
|
|
});
|
|
|
|
const sensitiveInfo = await ExportService.detectSensitiveColumns(cleanTable.id);
|
|
|
|
expect(sensitiveInfo.hasSensitive).toBe(false);
|
|
expect(sensitiveInfo.columns).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// LEVEL 2: PROJECT ROUND-TRIP
|
|
// =========================================================================
|
|
|
|
describe('Project Round-Trip', () => {
|
|
let testSpace;
|
|
let testProject;
|
|
let userId;
|
|
|
|
beforeEach(async () => {
|
|
userId = await createTestUser();
|
|
testSpace = await createTestSpace(userId);
|
|
testProject = await createTestProject(testSpace.id, userId, { name: 'CRM Project' });
|
|
|
|
// Create tables
|
|
await createTestTableWithData(testProject.id, {
|
|
name: 'customers',
|
|
columns: [
|
|
{ name: 'name', type: 'text' },
|
|
{ name: 'email', type: 'email' }
|
|
],
|
|
rows: [
|
|
{ name: 'Customer 1', email: 'c1@test.com' },
|
|
{ name: 'Customer 2', email: 'c2@test.com' }
|
|
]
|
|
});
|
|
|
|
await createTestTableWithData(testProject.id, {
|
|
name: 'orders',
|
|
columns: [
|
|
{ name: 'order_id', type: 'text' },
|
|
{ name: 'amount', type: 'number' }
|
|
],
|
|
rows: [
|
|
{ order_id: 'ORD-001', amount: 100 }
|
|
]
|
|
});
|
|
|
|
await createTestTableWithData(testProject.id, {
|
|
name: 'api_keys',
|
|
columns: [
|
|
{ name: 'service', type: 'text' },
|
|
{ name: 'api_key', type: 'apiKey' }
|
|
],
|
|
rows: [
|
|
{ service: 'Stripe', api_key: 'sk_test_123' }
|
|
]
|
|
});
|
|
|
|
// Create documents (may fail if table doesn't exist)
|
|
const doc1 = await createTestDocument(testProject.id, { title: 'Test README' });
|
|
const doc2 = await createTestDocument(testProject.id, { title: 'Test API Guide' });
|
|
// Track how many docs were actually created
|
|
testProject.docsCreated = [doc1, doc2].filter(Boolean).length;
|
|
});
|
|
|
|
it('exports project and imports identical copy to same space', async () => {
|
|
// 1. Export project with all tables FULL
|
|
const exported = await ExportService.exportProject(testProject.id, {
|
|
tables: {
|
|
'customers': 'full',
|
|
'orders': 'full',
|
|
'api_keys': 'full'
|
|
},
|
|
includeDocuments: true
|
|
});
|
|
|
|
// 2. Verify structure
|
|
expect(exported.type).toBe('project');
|
|
expect(exported.project.name).toBe('CRM Project');
|
|
expect(exported.tables).toHaveLength(3);
|
|
// Documents may be empty if table doesn't exist in test DB
|
|
expect(exported.documents).toHaveLength(testProject.docsCreated || 0);
|
|
|
|
// 3. Import to same space
|
|
const imported = await ImportService.importProject(testSpace.id, exported, {
|
|
mode: 'create',
|
|
newName: 'CRM Project Copy'
|
|
});
|
|
|
|
// 4. Verify
|
|
expect(imported.projectId).toBeDefined();
|
|
expect(imported.tablesImported).toBe(3);
|
|
expect(imported.documentsImported).toBe(testProject.docsCreated || 0);
|
|
|
|
// 5. Verify project was created
|
|
const newProject = await dbGet('SELECT * FROM projects WHERE id = ?', [imported.projectId]);
|
|
expect(newProject.name).toBe('CRM Project Copy');
|
|
});
|
|
|
|
it('exports project with mixed table modes', async () => {
|
|
const exported = await ExportService.exportProject(testProject.id, {
|
|
tables: {
|
|
'customers': 'full',
|
|
'orders': 'schema_only',
|
|
'api_keys': 'sanitized'
|
|
},
|
|
includeDocuments: true
|
|
});
|
|
|
|
// Verify each table exported correctly
|
|
const customersTable = exported.tables.find(t => t.table.name === 'customers');
|
|
const ordersTable = exported.tables.find(t => t.table.name === 'orders');
|
|
const apiKeysTable = exported.tables.find(t => t.table.name === 'api_keys');
|
|
|
|
expect(customersTable.rows.length).toBeGreaterThan(0);
|
|
expect(ordersTable.rows).toHaveLength(0); // schema_only
|
|
expect(apiKeysTable.meta.sanitizedColumns).toBeDefined();
|
|
expect(apiKeysTable.rows[0].api_key).toBeNull(); // sanitized
|
|
});
|
|
|
|
it('excludes tables marked as exclude', async () => {
|
|
const exported = await ExportService.exportProject(testProject.id, {
|
|
tables: {
|
|
'customers': 'full',
|
|
'orders': 'exclude',
|
|
'api_keys': 'exclude'
|
|
}
|
|
});
|
|
|
|
expect(exported.tables).toHaveLength(1);
|
|
expect(exported.tables[0].table.name).toBe('customers');
|
|
});
|
|
|
|
it('exports all tables with wildcard', async () => {
|
|
const exported = await ExportService.exportProject(testProject.id, {
|
|
tables: { '*': 'full' },
|
|
includeDocuments: false
|
|
});
|
|
|
|
expect(exported.tables).toHaveLength(3);
|
|
expect(exported.documents).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// LEVEL 3: SPACE ROUND-TRIP
|
|
// =========================================================================
|
|
|
|
describe('Space Round-Trip', () => {
|
|
let testSpace;
|
|
let userId;
|
|
|
|
beforeEach(async () => {
|
|
userId = await createTestUser();
|
|
testSpace = await createTestSpace(userId, { name: 'My Workspace' });
|
|
|
|
// Create Project 1
|
|
const project1 = await createTestProject(testSpace.id, userId, { name: 'CRM' });
|
|
await createTestTableWithData(project1.id, {
|
|
name: 'clients',
|
|
columns: [{ name: 'name', type: 'text' }],
|
|
rows: [{ name: 'Client 1' }]
|
|
});
|
|
|
|
// Create Project 2
|
|
const project2 = await createTestProject(testSpace.id, userId, { name: 'Analytics' });
|
|
await createTestTableWithData(project2.id, {
|
|
name: 'reports',
|
|
columns: [{ name: 'title', type: 'text' }],
|
|
rows: [{ title: 'Report 1' }]
|
|
});
|
|
});
|
|
|
|
it('exports space and imports as new space with identical content', async () => {
|
|
// 1. Export entire space
|
|
const exported = await ExportService.exportSpace(testSpace.id, {
|
|
projects: {
|
|
'CRM': { tables: { '*': 'full' } },
|
|
'Analytics': { tables: { '*': 'full' } }
|
|
},
|
|
includeSettings: true
|
|
}, { userId });
|
|
|
|
// 2. Verify structure
|
|
expect(exported.type).toBe('space');
|
|
expect(exported.space.name).toBe('My Workspace');
|
|
expect(exported.projects).toHaveLength(2);
|
|
|
|
// 3. Import as new space
|
|
const imported = await ImportService.importSpace(exported, {
|
|
newName: 'My Workspace Copy',
|
|
ownerId: userId
|
|
});
|
|
|
|
// 4. Verify
|
|
expect(imported.spaceId).toBeDefined();
|
|
expect(imported.projectsImported).toBe(2);
|
|
|
|
// 5. Verify space was created
|
|
const newSpace = await dbGet('SELECT * FROM spaces WHERE id = ?', [imported.spaceId]);
|
|
expect(newSpace.name).toBe('My Workspace Copy');
|
|
|
|
// 6. Verify projects were created
|
|
const newProjects = await dbAll('SELECT * FROM projects WHERE space_id = ?', [imported.spaceId]);
|
|
expect(newProjects).toHaveLength(2);
|
|
});
|
|
|
|
it('handles export when user is not admin/owner (should fail)', async () => {
|
|
const regularUserId = await createTestUser({ name: 'Regular User' });
|
|
|
|
await expect(
|
|
ExportService.exportSpace(testSpace.id, {}, { userId: regularUserId })
|
|
).rejects.toThrow('Access denied: only admin or owner can export');
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// ACCESS CONTROL TESTS
|
|
// =========================================================================
|
|
|
|
describe('Access Control', () => {
|
|
let testSpace;
|
|
let ownerId;
|
|
|
|
beforeEach(async () => {
|
|
ownerId = await createTestUser({ name: 'Space Owner' });
|
|
testSpace = await createTestSpace(ownerId);
|
|
});
|
|
|
|
it('allows export for space owner', async () => {
|
|
const canExport = await ExportService.canExport(ownerId, 'space', testSpace.id);
|
|
expect(canExport).toBe(true);
|
|
});
|
|
|
|
it('denies export for non-member', async () => {
|
|
const outsiderId = await createTestUser({ name: 'Outsider' });
|
|
|
|
const canExport = await ExportService.canExport(outsiderId, 'space', testSpace.id);
|
|
expect(canExport).toBe(false);
|
|
});
|
|
|
|
it('checks table export access through project and space', async () => {
|
|
const project = await createTestProject(testSpace.id, ownerId);
|
|
const table = await createTestTable(project.id);
|
|
|
|
// Owner can export table
|
|
const canExport = await ExportService.canExport(ownerId, 'table', table.id);
|
|
expect(canExport).toBe(true);
|
|
|
|
// Non-member cannot
|
|
const outsiderId = await createTestUser({ name: 'Outsider2' });
|
|
const cannotExport = await ExportService.canExport(outsiderId, 'table', table.id);
|
|
expect(cannotExport).toBe(false);
|
|
});
|
|
|
|
it('checks project export access through space', async () => {
|
|
const project = await createTestProject(testSpace.id, ownerId);
|
|
|
|
// Owner can export project
|
|
const canExport = await ExportService.canExport(ownerId, 'project', project.id);
|
|
expect(canExport).toBe(true);
|
|
|
|
// Non-member cannot
|
|
const outsiderId = await createTestUser({ name: 'Outsider3' });
|
|
const cannotExport = await ExportService.canExport(outsiderId, 'project', project.id);
|
|
expect(cannotExport).toBe(false);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// EDGE CASES
|
|
// =========================================================================
|
|
|
|
describe('Edge Cases', () => {
|
|
let testSpace;
|
|
let testProject;
|
|
let userId;
|
|
|
|
beforeEach(async () => {
|
|
userId = await createTestUser();
|
|
testSpace = await createTestSpace(userId);
|
|
testProject = await createTestProject(testSpace.id, userId);
|
|
});
|
|
|
|
it('handles empty table export', async () => {
|
|
const emptyTable = await createTestTableWithData(testProject.id, {
|
|
name: 'empty_table_test',
|
|
columns: [{ name: 'name', type: 'text' }],
|
|
rows: []
|
|
});
|
|
|
|
const exported = await ExportService.exportTable(emptyTable.id, { mode: 'full' });
|
|
expect(exported.rows).toHaveLength(0);
|
|
expect(exported.columns).toHaveLength(1);
|
|
});
|
|
|
|
it('handles table with no columns', async () => {
|
|
const table = await createTestTable(testProject.id, { name: 'no_columns_test' });
|
|
|
|
const exported = await ExportService.exportTable(table.id, { mode: 'full' });
|
|
expect(exported.rows).toHaveLength(0);
|
|
expect(exported.columns).toHaveLength(0);
|
|
});
|
|
|
|
it('handles project with no tables', async () => {
|
|
const emptyProject = await createTestProject(testSpace.id, userId, { name: 'Empty Project Test' });
|
|
|
|
const exported = await ExportService.exportProject(emptyProject.id, {
|
|
tables: { '*': 'full' }
|
|
});
|
|
|
|
expect(exported.tables).toHaveLength(0);
|
|
});
|
|
|
|
it('rejects invalid export data on import', async () => {
|
|
await expect(
|
|
ImportService.importTable(testProject.id, { type: 'invalid' }, { mode: 'create' })
|
|
).rejects.toThrow('Invalid data type');
|
|
});
|
|
});
|
|
});
|