Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions apps/e2e/src/tests/sql-editor-sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,17 @@ describe.skipIf(!ready)('SQL Editor · SQLite multi-credential', () => {
await driver.waitForSelector('[data-testid="sql-result-compare-toggle-0"]', {
timeout: 10_000,
});
// Compare data is off by default — plain side-by-side until opted in.
expect(await sql.compareToggle(0).isChecked()).toBe(false);
expect(await sql.diffCellCount()).toBe(0);
expect(await driver.locator('[data-testid="sql-data-migrate-bar-0"]').count()).toBe(0);

await sql.compareToggle(0).click();
expect(await sql.compareToggle(0).isChecked()).toBe(true);
expect(await sql.compareLegend(0).isVisible()).toBe(true);
expect(await sql.compareBaselineSelect(0).isVisible()).toBe(true);

// Wait for highlight pass after Compare defaults on.
// Wait for highlight pass after Compare is turned on.
await driver.waitForFunction(
() =>
document.querySelectorAll('[data-testid="sql-results-side-by-side"] td[data-diff]').length >
Expand All @@ -134,14 +140,35 @@ describe.skipIf(!ready)('SQL Editor · SQLite multi-credential', () => {
expect(anyDiff).toBeGreaterThanOrEqual(modified);

const results = await sql.resultsText();
expect(results).toMatch(/baseline/i);
expect(results).toMatch(/baseline|source/i);
expect(results).toMatch(/differ|match/i);

// Capture the colored compare view for the PR / walkthrough.
// Data migrate bar: Add / Edit / Delete (user opts in; none checked by default).
await driver.waitForSelector('[data-testid="sql-data-migrate-bar-0"]', {
timeout: 10_000,
});
expect(await driver.locator('[data-testid="sql-data-migrate-insert-0"]').isVisible()).toBe(
true
);
expect(await driver.locator('[data-testid="sql-data-migrate-insert-0"]').isChecked()).toBe(
false
);
expect(await driver.locator('[data-testid="sql-data-migrate-update-0"]').isChecked()).toBe(
false
);
expect(await driver.locator('[data-testid="sql-data-migrate-delete-0"]').isChecked()).toBe(
false
);
expect(await driver.locator('[data-testid="sql-data-migrate-tx-0"]').isVisible()).toBe(true);
expect(await driver.locator('[data-testid="sql-data-migrate-identity-0"]').isVisible()).toBe(
true
);

// Capture the colored compare + migrate bar for the PR / walkthrough.
await saveScreenshot(driver, 'sql-editor-data-compare');
await saveSeoScreenshot(driver, 'sql-editor-data-compare');

// Toggle Compare off → highlights clear.
// Toggle Compare off → highlights and migrate bar clear.
await sql.compareToggle(0).click();
await driver.waitForFunction(
() =>
Expand All @@ -150,6 +177,7 @@ describe.skipIf(!ready)('SQL Editor · SQLite multi-credential', () => {
{ timeout: 10_000 }
);
expect(await sql.diffCellCount()).toBe(0);
expect(await driver.locator('[data-testid="sql-data-migrate-bar-0"]').count()).toBe(0);
});

it('shows the statement strip for multi-statement SQL', async () => {
Expand Down
146 changes: 146 additions & 0 deletions apps/web/src/backend/api/data-migrate-execute.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { ConnectionFactory } from '@foxschema/db';
import { executeDataMigrateOps } from './data-migrate-execute';
import { getAdapter } from '@foxschema/db';

async function seedDb(dbPath: string): Promise<void> {
// @ts-expect-error no type declarations for better-sqlite3
const mod = (await import('better-sqlite3')) as {
default: new (path: string) => { exec(sql: string): void; close(): void };
};
const db = new mod.default(dbPath);
db.exec(`
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT);
INSERT INTO customers (id, name) VALUES (1, 'Bob');
INSERT INTO customers (id, name) VALUES (2, 'Shared');
`);
db.close();
}

describe('executeDataMigrateOps', () => {
let dbPath: string;

beforeEach(async () => {
await ConnectionFactory.closeAll().catch(() => {});
dbPath = join(tmpdir(), `fox-data-migrate-${process.pid}-${Date.now()}.db`);
await seedDb(dbPath);
});

afterEach(async () => {
await ConnectionFactory.closeAll().catch(() => {});
rmSync(dbPath, { force: true });
});

it('atomic transaction rolls back all ops on failure', async () => {
const out = await executeDataMigrateOps(
'sqlite',
{ connectionString: dbPath },
undefined,
[
{
op: 'update',
key: 'id=1',
sql: `UPDATE customers SET name = 'Alice' WHERE id = 1`,
},
{
op: 'insert',
key: 'id=bad',
sql: `INSERT INTO missing_table (id) VALUES (9)`,
},
{
op: 'insert',
key: 'id=3',
sql: `INSERT INTO customers (id, name) VALUES (3, 'New')`,
},
],
{ useTransaction: true, continueOnError: false }
);
expect(out.rolledBack).toBe(true);
expect(out.failCount).toBe(1);
expect(out.results.map((r) => r.status)).toEqual(['SUCCESS', 'FAILED', 'SKIPPED']);

await ConnectionFactory.closeAll().catch(() => {});
const conn = await ConnectionFactory.create('sqlite', { connectionString: dbPath });
try {
const rows = await getAdapter('sqlite').query<{ name: string }>(
conn,
'SELECT name FROM customers WHERE id = 1',
[]
);
expect(rows[0]?.name).toBe('Bob');
} finally {
await ConnectionFactory.close('sqlite', conn);
}
});

it('continueOnError keeps going and commits successful ops', async () => {
const out = await executeDataMigrateOps(
'sqlite',
{ connectionString: dbPath },
undefined,
[
{
op: 'update',
key: 'id=1',
sql: `UPDATE customers SET name = 'Alice' WHERE id = 1`,
},
{
op: 'insert',
key: 'id=bad',
sql: `INSERT INTO missing_table (id) VALUES (9)`,
},
{
op: 'insert',
key: 'id=3',
sql: `INSERT INTO customers (id, name) VALUES (3, 'New')`,
},
],
{ useTransaction: false, continueOnError: true }
);
expect(out.failCount).toBe(1);
expect(out.results.map((r) => r.status)).toEqual(['SUCCESS', 'FAILED', 'SUCCESS']);

await ConnectionFactory.closeAll().catch(() => {});
const conn = await ConnectionFactory.create('sqlite', { connectionString: dbPath });
try {
const rows = await getAdapter('sqlite').query<{ name: string }>(
conn,
'SELECT name FROM customers ORDER BY id',
[]
);
expect(rows.map((r) => r.name)).toEqual(['Alice', 'Shared', 'New']);
} finally {
await ConnectionFactory.close('sqlite', conn);
}
});

it('stop without transaction skips remaining after first failure', async () => {
const out = await executeDataMigrateOps(
'sqlite',
{ connectionString: dbPath },
undefined,
[
{
op: 'insert',
key: 'id=bad',
sql: `INSERT INTO missing_table (id) VALUES (9)`,
},
{
op: 'update',
key: 'id=1',
sql: `UPDATE customers SET name = 'Alice' WHERE id = 1`,
},
],
{ useTransaction: false, continueOnError: false }
);
expect(out.results.map((r) => r.status)).toEqual(['FAILED', 'SKIPPED']);
});
});
Loading
Loading