155 lines
5.5 KiB
TypeScript
155 lines
5.5 KiB
TypeScript
/**
|
|
* Integration test — /v1/profiles batch endpoint SQL.
|
|
*
|
|
* Exercises the SELECT pattern the batch handler uses. Mirrors the
|
|
* SQL in src/api/profiles.ts (batch branch); if that changes, this
|
|
* test should change too.
|
|
*
|
|
* We test the SQL directly rather than spinning up the HTTP server
|
|
* for the same reason orderbook-join.test.ts does: the meaningful
|
|
* logic lives in the SQL, and the HTTP layer is a thin validator
|
|
* that's simple enough to cover by human review. Integration here
|
|
* catches the one thing unit tests can't: PG behavior on
|
|
* `ANY($1::text[])` with various inputs.
|
|
*/
|
|
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
INTEGRATION_ENABLED,
|
|
setupWithMigrations,
|
|
truncateAll,
|
|
type IntegrationFixture
|
|
} from './harness';
|
|
|
|
/** Mirror of the SELECT in src/api/profiles.ts batch handler. */
|
|
const BATCH_SELECT = `
|
|
SELECT account, display_name, json_metadata,
|
|
source_block_num::text, updated_at
|
|
FROM profiles WHERE account = ANY($1::text[])
|
|
`;
|
|
|
|
/** Insert a profiles row. Values that aren't parameterized here
|
|
* (metadata, block_num) default to sensible empties. */
|
|
async function insertProfile(
|
|
fx: IntegrationFixture,
|
|
account: string,
|
|
display_name: string
|
|
): Promise<void> {
|
|
await fx.db.query(
|
|
`INSERT INTO profiles (
|
|
account, display_name, json_metadata,
|
|
source_block_num, source_trx_id, updated_at
|
|
) VALUES ($1, $2, $3, $4, $5, NOW())`,
|
|
[account, display_name, {}, 1, `0000000000000000000000000000000000000000`]
|
|
);
|
|
}
|
|
|
|
describe.skipIf(!INTEGRATION_ENABLED)('batch profiles endpoint — SQL integration', () => {
|
|
let fx: IntegrationFixture;
|
|
|
|
beforeAll(async () => {
|
|
fx = await setupWithMigrations();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (fx) await fx.teardown();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await truncateAll(fx);
|
|
});
|
|
|
|
it('returns zero rows for empty table', async () => {
|
|
const result = await fx.db.query(BATCH_SELECT, [['alice', 'bob']]);
|
|
expect(result.rowCount).toBe(0);
|
|
});
|
|
|
|
it('returns a single profile when one account matches', async () => {
|
|
await insertProfile(fx, 'alice', 'Alice');
|
|
const result = await fx.db.query<{ account: string; display_name: string }>(BATCH_SELECT, [
|
|
['alice']
|
|
]);
|
|
expect(result.rowCount).toBe(1);
|
|
expect(result.rows[0]!.account).toBe('alice');
|
|
expect(result.rows[0]!.display_name).toBe('Alice');
|
|
});
|
|
|
|
it('returns all matching profiles when many accounts match', async () => {
|
|
await insertProfile(fx, 'alice', 'Alice');
|
|
await insertProfile(fx, 'bob', 'Bob');
|
|
await insertProfile(fx, 'carol', 'Carol');
|
|
const result = await fx.db.query<{ account: string }>(BATCH_SELECT, [
|
|
['alice', 'bob', 'carol']
|
|
]);
|
|
expect(result.rowCount).toBe(3);
|
|
const accounts = result.rows.map((r) => r.account).sort();
|
|
expect(accounts).toEqual(['alice', 'bob', 'carol']);
|
|
});
|
|
|
|
it('returns only the known accounts on a mixed batch', async () => {
|
|
// The HTTP handler silently drops unknown accounts. Verified
|
|
// at the SQL level: the WHERE clause simply yields no row
|
|
// for an account that doesn't exist, so the result set
|
|
// contains only the knowns.
|
|
await insertProfile(fx, 'alice', 'Alice');
|
|
await insertProfile(fx, 'bob', 'Bob');
|
|
const result = await fx.db.query<{ account: string }>(BATCH_SELECT, [
|
|
['alice', 'nonexistent', 'bob', 'another-missing']
|
|
]);
|
|
expect(result.rowCount).toBe(2);
|
|
const accounts = result.rows.map((r) => r.account).sort();
|
|
expect(accounts).toEqual(['alice', 'bob']);
|
|
});
|
|
|
|
it('deduplicates at the application layer — SQL handles duplicates fine too', async () => {
|
|
// The HTTP handler dedupes before querying. But even if it
|
|
// didn't, ANY($1) with a duplicated array returns one row
|
|
// per matching account — the array deduplication is
|
|
// semantically transparent to the query. This test locks
|
|
// that behavior in.
|
|
await insertProfile(fx, 'alice', 'Alice');
|
|
const result = await fx.db.query<{ account: string }>(BATCH_SELECT, [
|
|
['alice', 'alice', 'alice']
|
|
]);
|
|
expect(result.rowCount).toBe(1);
|
|
expect(result.rows[0]!.account).toBe('alice');
|
|
});
|
|
|
|
it('returns the correct columns for serialization', async () => {
|
|
// The handler does parseInt on source_block_num::text (to
|
|
// dodge BIGINT becoming a string in pg's default serde),
|
|
// and calls .toISOString() on updated_at. Verify both
|
|
// column types as the query casts them.
|
|
await insertProfile(fx, 'alice', 'Alice');
|
|
const result = await fx.db.query<{
|
|
account: string;
|
|
display_name: string;
|
|
json_metadata: unknown;
|
|
source_block_num: string;
|
|
updated_at: Date;
|
|
}>(BATCH_SELECT, [['alice']]);
|
|
const row = result.rows[0]!;
|
|
expect(typeof row.source_block_num).toBe('string');
|
|
expect(row.updated_at).toBeInstanceOf(Date);
|
|
// json_metadata returns as a parsed object, not a string
|
|
// (pg's default JSONB decoder).
|
|
expect(typeof row.json_metadata).toBe('object');
|
|
});
|
|
|
|
it('handles a large batch at the max-size boundary (100)', async () => {
|
|
// Insert 100 accounts — fewer than we'd want if we were
|
|
// really testing load, but enough to prove the parameterized
|
|
// array handles the cap without pathological planning.
|
|
// Account names must pass the ACCOUNT_NAME_RE regex
|
|
// (lowercase, 3..16 chars, letter-start). Use "user001" etc.
|
|
const accounts: string[] = [];
|
|
for (let i = 0; i < 100; i++) {
|
|
const name = `user${String(i).padStart(3, '0')}`;
|
|
accounts.push(name);
|
|
await insertProfile(fx, name, `User ${i}`);
|
|
}
|
|
const result = await fx.db.query<{ account: string }>(BATCH_SELECT, [accounts]);
|
|
expect(result.rowCount).toBe(100);
|
|
});
|
|
});
|