325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
/**
|
|
* Integration test — migrations apply cleanly.
|
|
*
|
|
* Smoke test: if this fails, every other integration test
|
|
* cascades into meaningless noise. Keep the assertions light;
|
|
* if migrations grow richer (check constraints, triggers,
|
|
* materialised views), this test grows with them.
|
|
*/
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
|
|
import { INTEGRATION_ENABLED, setup, type IntegrationFixture } from './harness';
|
|
|
|
// If integration is disabled, we still register a describe so the
|
|
// skip is visible in the test output (rather than the suite just
|
|
// vanishing).
|
|
describe.skipIf(!INTEGRATION_ENABLED)('migrations — integration', () => {
|
|
let fx: IntegrationFixture;
|
|
|
|
beforeAll(async () => {
|
|
fx = await setup();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (fx) await fx.teardown();
|
|
});
|
|
|
|
it('applies the full migration chain (v1 → head) without SQL errors', async () => {
|
|
// applyMigrations runs runMigrations(db), which applies every
|
|
// entry in MIGRATIONS[] in order (v1 through the current head).
|
|
// It throws on any SQL error, which vitest turns into a failure.
|
|
await fx.applyMigrations();
|
|
});
|
|
|
|
it('creates every expected table', async () => {
|
|
// information_schema.tables, scoped to our test schema.
|
|
const res = await fx.db.query<{ table_name: string }>(
|
|
`SELECT table_name FROM information_schema.tables
|
|
WHERE table_schema = $1
|
|
ORDER BY table_name`,
|
|
[fx.schema]
|
|
);
|
|
const names = res.rows.map((r) => r.table_name);
|
|
expect(names).toEqual(
|
|
expect.arrayContaining([
|
|
'accounts',
|
|
'chat_messages',
|
|
'fee_transfers',
|
|
'feedback',
|
|
'feedback_responses',
|
|
'indexer_state',
|
|
'ops',
|
|
'orders',
|
|
'profiles',
|
|
'related_accounts',
|
|
'relay_pending_transfers',
|
|
'releases',
|
|
'schema_migrations',
|
|
'suspicious_reciprocity',
|
|
'witness_fee_history'
|
|
])
|
|
);
|
|
});
|
|
|
|
it('orders has the fee_status column added in v2', async () => {
|
|
const res = await fx.db.query<{ column_name: string; data_type: string }>(
|
|
`SELECT column_name, data_type FROM information_schema.columns
|
|
WHERE table_schema = $1 AND table_name = 'orders' AND column_name = 'fee_status'`,
|
|
[fx.schema]
|
|
);
|
|
expect(res.rowCount).toBe(1);
|
|
expect(res.rows[0]!.data_type).toBe('text');
|
|
});
|
|
|
|
it('fee_status CHECK constraint rejects invalid values', async () => {
|
|
// Insert a minimal valid order first.
|
|
await fx.db.query(
|
|
`INSERT INTO orders (
|
|
account, permlink, side, asset, fiat_currency,
|
|
price_model, payment_methods, status,
|
|
created_at, updated_at
|
|
) VALUES ('alice', 'sell-btc-usd-aaa', 'sell', 'BTC', 'USD',
|
|
'{}'::jsonb, ARRAY['cash'], 'live',
|
|
NOW(), NOW())`
|
|
);
|
|
// Now try to update its fee_status to something invalid.
|
|
await expect(
|
|
fx.db.query(`UPDATE orders SET fee_status = 'bogus' WHERE permlink = 'sell-btc-usd-aaa'`)
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('push_subscriptions has the muted_categories column added in v40 (all-on default)', async () => {
|
|
// cp450 GAP A — the per-category Web Push opt-in blocklist.
|
|
const res = await fx.db.query<{ column_name: string; data_type: string }>(
|
|
`SELECT column_name, data_type FROM information_schema.columns
|
|
WHERE table_schema = $1 AND table_name = 'push_subscriptions'
|
|
AND column_name = 'muted_categories'`,
|
|
[fx.schema]
|
|
);
|
|
expect(res.rowCount).toBe(1);
|
|
expect(res.rows[0]!.data_type).toBe('ARRAY');
|
|
// A row inserted without the column defaults to '{}' (all categories on).
|
|
await fx.db.query(
|
|
`INSERT INTO push_subscriptions (account, endpoint, p256dh, auth, privacy_mode)
|
|
VALUES ('mc_default', 'https://ep/mc', 'p', 'a', 'standard')`
|
|
);
|
|
const def = await fx.db.query<{ empty: boolean }>(
|
|
`SELECT muted_categories = '{}' AS empty FROM push_subscriptions
|
|
WHERE account = 'mc_default'`
|
|
);
|
|
expect(def.rows[0]!.empty).toBe(true);
|
|
});
|
|
|
|
it('push_pending has the notification_id column added in v41 (nullable dedup tag)', async () => {
|
|
// cp450 double-fire fix — shared tag so an order-signal push and its
|
|
// in-page notification collapse.
|
|
const res = await fx.db.query<{ column_name: string; is_nullable: string }>(
|
|
`SELECT column_name, is_nullable FROM information_schema.columns
|
|
WHERE table_schema = $1 AND table_name = 'push_pending'
|
|
AND column_name = 'notification_id'`,
|
|
[fx.schema]
|
|
);
|
|
expect(res.rowCount).toBe(1);
|
|
expect(res.rows[0]!.is_nullable).toBe('YES');
|
|
// An order-signal row carries the shared tag; a plain row leaves it null.
|
|
await fx.db.query(
|
|
`INSERT INTO push_pending (account, category, title, body, click_path, event_at, notification_id)
|
|
VALUES ('nid_order', 'order', 't', 'b', '/nid_order/o', NOW(), 'morphit-trade-o')`
|
|
);
|
|
await fx.db.query(
|
|
`INSERT INTO push_pending (account, category, title, body, click_path, event_at)
|
|
VALUES ('nid_plain', 'chat', 't', 'b', '/chat', NOW())`
|
|
);
|
|
const rows = await fx.db.query<{ account: string; notification_id: string | null }>(
|
|
`SELECT account, notification_id FROM push_pending
|
|
WHERE account IN ('nid_order', 'nid_plain') ORDER BY account`
|
|
);
|
|
const byAcct = Object.fromEntries(rows.rows.map((r) => [r.account, r.notification_id]));
|
|
expect(byAcct['nid_order']).toBe('morphit-trade-o');
|
|
expect(byAcct['nid_plain']).toBeNull();
|
|
});
|
|
|
|
it('related_accounts enforces canonical a < b ordering', async () => {
|
|
await expect(
|
|
fx.db.query(
|
|
`INSERT INTO related_accounts (account_a, account_b, reason)
|
|
VALUES ('zulu', 'alpha', 'test')` // b < a — violates CHECK
|
|
)
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
// ─── v4 additions ────────────────────────────────────────
|
|
it('accounts has the first_buy_waived_at column added in v4', async () => {
|
|
const res = await fx.db.query<{ column_name: string }>(
|
|
`SELECT column_name FROM information_schema.columns
|
|
WHERE table_schema = $1 AND table_name = 'accounts'
|
|
AND column_name IN ('first_buy_waived_at', 'first_trade_complete_at')
|
|
ORDER BY column_name`,
|
|
[fx.schema]
|
|
);
|
|
expect(res.rows.map((r) => r.column_name)).toEqual([
|
|
'first_buy_waived_at',
|
|
'first_trade_complete_at'
|
|
]);
|
|
});
|
|
|
|
it('relay_pending_transfers kind CHECK rejects invalid values', async () => {
|
|
// Valid kinds: 'liquid', 'vesting'. Anything else rejects.
|
|
await expect(
|
|
fx.db.query(
|
|
`INSERT INTO relay_pending_transfers
|
|
(recipient, kind, amount_blurt, reason, created_at)
|
|
VALUES ('alice', 'erroneous', 10, 'test', NOW())`
|
|
)
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('relay_pending_transfers amount_blurt CHECK rejects negative', async () => {
|
|
// v4 originally enforced amount_blurt > 0; schema-v6 relaxed
|
|
// this to amount_blurt >= 0 so delegation rows can use a zero
|
|
// sentinel (kind='delegation' uses the bp column instead).
|
|
// Negative values are still rejected.
|
|
await expect(
|
|
fx.db.query(
|
|
`INSERT INTO relay_pending_transfers
|
|
(recipient, kind, amount_blurt, reason, created_at)
|
|
VALUES ('alice', 'liquid', -5, 'test', NOW())`
|
|
)
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('witness_fee_history observation_kind CHECK is enforced', async () => {
|
|
// Valid values: 'initial', 'change'. 'rebase' is not.
|
|
await expect(
|
|
fx.db.query(
|
|
`INSERT INTO witness_fee_history
|
|
(observed_at, account_creation_fee_blurt, observation_kind)
|
|
VALUES (NOW(), 100, 'rebase')`
|
|
)
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('witness_fee_history account_creation_fee_blurt CHECK rejects negative', async () => {
|
|
// The chain should never report a negative fee, but the
|
|
// DB defends against it regardless.
|
|
await expect(
|
|
fx.db.query(
|
|
`INSERT INTO witness_fee_history
|
|
(observed_at, account_creation_fee_blurt, observation_kind)
|
|
VALUES (NOW(), -1, 'initial')`
|
|
)
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
// ─── Collapse regression (May 2026 audit) ────────────────
|
|
// The migration runner was collapsed from 27 incremental files
|
|
// (schema.sql + schema-v2.sql ... schema-v27.sql) into a single
|
|
// canonical schema.sql. The runner records all 27 versions in
|
|
// schema_migrations on a fresh DB so any downstream "is v15
|
|
// applied?" check still returns true. These tests lock that
|
|
// behavior in.
|
|
|
|
it('records all 27 historical versions in schema_migrations on a fresh DB', async () => {
|
|
const res = await fx.db.query<{ version: number }>(
|
|
`SELECT version FROM schema_migrations
|
|
WHERE version BETWEEN 1 AND 27
|
|
ORDER BY version`
|
|
);
|
|
const versions = res.rows.map((r) => r.version);
|
|
expect(versions).toEqual([
|
|
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
|
27
|
|
]);
|
|
});
|
|
|
|
it('subsumed versions (v2-v27) have descriptions referencing v1', async () => {
|
|
// The runner records subsumed versions with a description
|
|
// like "subsumed by v1 (collapsed canonical schema ...)".
|
|
// This makes it obvious in operator dashboards that v2-v27
|
|
// weren't separately applied — they're part of v1.
|
|
const res = await fx.db.query<{
|
|
version: number;
|
|
description: string;
|
|
}>(
|
|
`SELECT version, description FROM schema_migrations
|
|
WHERE version BETWEEN 2 AND 27
|
|
ORDER BY version
|
|
LIMIT 1`
|
|
);
|
|
expect(res.rowCount).toBe(1);
|
|
expect(res.rows[0]!.description).toContain('subsumed by v1');
|
|
});
|
|
|
|
it('every v1-v27 table from the historical migrations exists', async () => {
|
|
// The collapsed schema must produce a superset of tables
|
|
// from every individual historical migration. Spot-check
|
|
// every distinct table introduced anywhere in v1-v27.
|
|
const expectedTables = [
|
|
// v1
|
|
'orders',
|
|
'feedback',
|
|
'feedback_responses',
|
|
'profiles',
|
|
'related_accounts',
|
|
'suspicious_reciprocity',
|
|
'releases',
|
|
'ops',
|
|
'indexer_state',
|
|
'schema_migrations',
|
|
// v2
|
|
'fee_transfers',
|
|
// v3
|
|
'accounts',
|
|
// v4
|
|
'relay_pending_transfers',
|
|
'witness_fee_history',
|
|
// v5
|
|
'fee_attestations',
|
|
// v6
|
|
'account_loyalty',
|
|
'account_loyalty_milestones',
|
|
// v7
|
|
'operators',
|
|
'operator_earnings',
|
|
'operator_registration_events',
|
|
// v8
|
|
'chat_messages',
|
|
// v9
|
|
'featured_slot_bids',
|
|
// v13
|
|
'chat_identities',
|
|
// v14
|
|
'chat_read_state',
|
|
// v15
|
|
'blocks',
|
|
// v16
|
|
'stranger_fees',
|
|
// v21
|
|
'known_instances',
|
|
// v22
|
|
'order_views',
|
|
// v23
|
|
'operator_blocks',
|
|
// v24
|
|
'instance_payment_methods',
|
|
// v27
|
|
'operator_attribution_events'
|
|
// NOTE: `operator_payouts` was introduced at v27 but RETIRED at
|
|
// cp408 (a v28+ change) — see schema.sql "operator_payouts:
|
|
// RETIRED (cp408)". The runner applies the consolidated schema.sql
|
|
// (current state), which correctly omits the table, so it must NOT
|
|
// appear in this expected-superset. (No live code references it;
|
|
// the collapsed baseline is authoritative.)
|
|
];
|
|
const res = await fx.db.query<{ table_name: string }>(
|
|
`SELECT table_name FROM information_schema.tables
|
|
WHERE table_schema = $1
|
|
ORDER BY table_name`,
|
|
[fx.schema]
|
|
);
|
|
const actual = new Set(res.rows.map((r) => r.table_name));
|
|
const missing = expectedTables.filter((t) => !actual.has(t));
|
|
expect(missing).toEqual([]);
|
|
});
|
|
});
|