diff --git a/.vscode/launch.json b/.vscode/launch.json index f2b8f38..def10c4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -16,6 +16,27 @@ "TS_NODE_SKIP_IGNORE": "true" }, "killBehavior": "polite" - } + }, + { + "type": "node", + "request": "launch", + "name": "Jest", + "program": "${workspaceFolder}/api/node_modules/jest/bin/jest", + "cwd": "${workspaceFolder}/api/", + "args": [ + "--testTimeout=3600000", + "--runInBand", + "--no-cache", + ], + "outputCapture": "std", + "console": "integratedTerminal", + "preLaunchTask": "npm: testenv:run", + "postDebugTask": "npm: testenv:stop", + "env": { + "PGHOST": "localhost", + "PGUSER": "postgres", + "PGPASSWORD": "postgres", + }, + }, ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..3d03aae --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,43 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "npm: testenv:run", + "type": "shell", + "command": "npm run testenv:run -- -d", + "isBackground": true, + "options": { + "cwd": "${workspaceFolder}/api/", + }, + "problemMatcher": { + "pattern": { + "regexp": ".", + "file": 1, + "location": 2, + "message": 3 + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".", + "endsPattern": "." + } + } + }, + { + "label": "npm: testenv:stop", + "type": "shell", + "command": "npm run testenv:stop", + "options": { + "cwd": "${workspaceFolder}/api/", + }, + "presentation": { + "echo": true, + "reveal": "silent", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + } + } + ] +} diff --git a/api/package.json b/api/package.json index f629b42..21320c9 100644 --- a/api/package.json +++ b/api/package.json @@ -14,7 +14,10 @@ "generate:vercel": "npm run generate:git-info && npm run generate:openapi && npm run generate:docs", "lint:eslint": "eslint . --ext .ts,.tsx -f unix", "lint:prettier": "prettier --check src/**/*.ts tests/**/*.ts", - "lint:unused-exports": "ts-unused-exports tsconfig.json --showLineNumber --excludePathsFromReport=util/*" + "lint:unused-exports": "ts-unused-exports tsconfig.json --showLineNumber --excludePathsFromReport=util/*", + "testenv:run": "docker-compose -f ../docker/docker-compose.dev.postgres.yml up", + "testenv:stop": "docker-compose -f ../docker/docker-compose.dev.postgres.yml down -v -t 0", + "testenv:logs": "docker-compose -f ../docker/docker-compose.dev.postgres.yml logs -t -f" }, "author": "Hiro Systems PBC (https://hiro.so)", "license": "Apache 2.0", diff --git a/api/src/api/schemas.ts b/api/src/api/schemas.ts index 12cf13a..d873e82 100644 --- a/api/src/api/schemas.ts +++ b/api/src/api/schemas.ts @@ -61,7 +61,9 @@ const RuneNumberSchema = Type.RegEx(/^[0-9]+$/, { title: 'Rune number' }); export const RuneNumberSchemaCType = TypeCompiler.Compile(RuneNumberSchema); const RuneNameSchema = Type.RegEx(/^[A-Z]+$/, { title: 'Rune name' }); export const RuneNameSchemaCType = TypeCompiler.Compile(RuneNameSchema); -const RuneSpacedNameSchema = Type.RegEx(/^[A-Z](•[A-Z]+)+$/, { title: 'Rune name with spacers' }); +const RuneSpacedNameSchema = Type.RegEx(/^[A-Za-z]+(•[A-Za-z]+)+$/, { + title: 'Rune name with spacers', +}); export const RuneSpacedNameSchemaCType = TypeCompiler.Compile(RuneSpacedNameSchema); export const RuneSchema = Type.Union([ diff --git a/api/src/pg/types.ts b/api/src/pg/types.ts index ce058c4..99e5c28 100644 --- a/api/src/pg/types.ts +++ b/api/src/pg/types.ts @@ -5,7 +5,7 @@ export type DbPaginatedResult = { export type DbCountedQueryResult = T & { total: number }; -type DbRune = { +export type DbRune = { id: string; number: number; name: string; diff --git a/api/tests/api/api.test.ts b/api/tests/api/api.test.ts index 4773567..345450c 100644 --- a/api/tests/api/api.test.ts +++ b/api/tests/api/api.test.ts @@ -1,3 +1,110 @@ -test('sample', () => { - expect(true); +import { ENV } from '../../src/env'; +import { PgStore } from '../../src/pg/pg-store'; +import { + dropDatabase, + insertDbLedgerEntry, + insertRune, + sampleRune, + runMigrations, + startTestApiServer, + TestFastifyServer, + insertSupplyChange, + sampleLedgerEntry, +} from '../helpers'; + +describe('Etchings', () => { + let db: PgStore; + let fastify: TestFastifyServer; + + const rune = sampleRune('1:1', 'Sample Rune'); + const ledgerEntry = sampleLedgerEntry(rune.id); + + beforeEach(async () => { + ENV.PGDATABASE = 'postgres'; + db = await PgStore.connect(); + fastify = await startTestApiServer(db); + await runMigrations(db); + await insertRune(db, rune); + const event_index = 0; + await insertDbLedgerEntry(db, ledgerEntry, event_index); + await insertSupplyChange(db, rune.id, 1); + }); + + afterEach(async () => { + if (fastify) { + await fastify.close(); + } + + await dropDatabase(db); + await db.close(); + }); + + test('lists runes', async () => { + const expected = { + divisibility: 0, + id: '1:1', + location: { + block_hash: '0000000000000000000320283a032748cef8227873ff4872689bf23f1cda83a5', + block_height: 840000, + timestamp: 0, + tx_id: '2bb85f4b004be6da54f766c17c1e855187327112c231ef2ff35ebad0ea67c69e', + tx_index: 1, + }, + mint_terms: { + amount: '100', + cap: '5000000', + height_end: null, + height_start: null, + offset_end: null, + offset_start: null, + }, + name: 'Sample Rune', + number: 1, + spaced_name: 'Sample•Rune', + supply: { + burned: '0', + current: '0', + mint_percentage: '0.0000', + mintable: false, + minted: '0', + premine: '0', + total_burns: '0', + total_mints: '0', + }, + symbol: 'ᚠ', + turbo: false, + }; + const runesResponse = await fastify.inject({ + method: 'GET', + url: '/runes/v1/etchings', + }); + expect(runesResponse.statusCode).toBe(200); + expect(runesResponse.json().results).not.toHaveLength(0); + + const response = await fastify.inject({ + method: 'GET', + url: '/runes/v1/etchings/' + ledgerEntry.rune_id, + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toStrictEqual(expected); + }); + + test('can fetch by spaced name', async () => { + const url = '/runes/v1/etchings/' + rune.spaced_name; + const response = await fastify.inject({ + method: 'GET', + url: url, + }); + expect(response.statusCode).toBe(200); + expect(response.json().spaced_name).toEqual(rune.spaced_name); + }); + + test('can not fetch by spaced name if lacking bullets', async () => { + const url = '/runes/v1/etchings/' + rune.spaced_name.replaceAll('•', '-'); + const response = await fastify.inject({ + method: 'GET', + url: url, + }); + expect(response.statusCode).toBe(400); + }); }); diff --git a/api/tests/helpers.ts b/api/tests/helpers.ts new file mode 100644 index 0000000..baf74ce --- /dev/null +++ b/api/tests/helpers.ts @@ -0,0 +1,200 @@ +import { readdirSync } from 'fs'; +import { PgStore } from '../src/pg/pg-store'; +import { FastifyBaseLogger, FastifyInstance } from 'fastify'; +import { IncomingMessage, Server, ServerResponse } from 'http'; +import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { buildApiServer } from '../src/api/init'; +import { Rune } from '../src/api/schemas'; +import { DbLedgerEntry, DbRune } from '../src/pg/types'; + +export type TestFastifyServer = FastifyInstance< + Server, + IncomingMessage, + ServerResponse, + FastifyBaseLogger, + TypeBoxTypeProvider +>; + +export async function startTestApiServer(db: PgStore): Promise { + return await buildApiServer({ db }); +} + +export async function runMigrations(db: PgStore) { + const contents = readdirSync('../migrations'); + await db.sqlWriteTransaction(async sql => { + for (const fileName of contents) { + if (!fileName.endsWith('.sql')) continue; + await db.sql.file('../migrations/' + fileName); + } + }); +} + +export async function dropDatabase(db: PgStore) { + await db.sqlWriteTransaction(async sql => { + // Drop all tables. + await sql` + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = current_schema()) LOOP + EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE'; + END LOOP; + END $$ + `; + // Drop all types. + await sql` + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN (SELECT typname FROM pg_type WHERE typtype = 'e' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) LOOP + EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE'; + END LOOP; + END $$; + `; + }); +} +export function sampleLedgerEntry(rune_id: string, block_height?: string): DbLedgerEntry { + return { + rune_id: '1:1', + block_hash: '0000000000000000000320283a032748cef8227873ff4872689bf23f1cda83a5', + block_height: block_height || '840000', + tx_index: 0, + tx_id: '2bb85f4b004be6da54f766c17c1e855187327112c231ef2ff35ebad0ea67c69e', + output: 0, + address: '0', + receiver_address: '0', + amount: '0', + operation: 'etching', + timestamp: 0, + }; +} + +function toSpacedName(name: string | null): string | null { + if (name === null) { + return null; + } + // should take "Some name" and make it "Some•name" + const words = name.split(' '); + return words.join('•'); +} +export function sampleRune(id: string, name?: string): DbRune { + return { + id: '1:1', + name: name || 'SAMPLERUNENAME', + spaced_name: (name && toSpacedName(name)) || 'SAMPLE•RUNE•NAME', + number: 1, + block_hash: '0000000000000000000320283a032748cef8227873ff4872689bf23f1cda83a5', + block_height: '840000', + tx_index: 1, + tx_id: '2bb85f4b004be6da54f766c17c1e855187327112c231ef2ff35ebad0ea67c69e', + divisibility: 2, + premine: '1000', + symbol: 'ᚠ', + cenotaph: true, + terms_amount: '100', + terms_cap: '5000000', + terms_height_start: null, + terms_height_end: null, + terms_offset_start: null, + terms_offset_end: null, + turbo: false, + minted: '1000', + total_mints: '1500', + burned: '500', + total_burns: '750', + total_operations: '1', + timestamp: 1713571767, + }; +} + +export async function insertDbLedgerEntry( + db: PgStore, + payload: DbLedgerEntry, + event_index: number +): Promise { + await db.sqlWriteTransaction(async sql => { + const { + rune_id, + block_hash, + block_height, + tx_index, + tx_id, + output, + address, + receiver_address, + amount, + operation, + } = payload; + + await sql` + INSERT INTO ledger ( + rune_id, block_hash, block_height, tx_index, tx_id, output, + address, receiver_address, amount, operation, timestamp, event_index + ) + VALUES ( + + ${rune_id}, ${block_hash}, ${block_height}, ${tx_index}, ${tx_id}, ${output}, ${address}, ${receiver_address}, ${amount}, ${operation}, 0, ${event_index} + ) + `; + }); +} + +export async function insertSupplyChange( + db: PgStore, + rune_id: string, + block_height: number, + minted?: number, + total_mints?: number, + total_operations?: number +): Promise { + await db.sqlWriteTransaction(async sql => { + const burned = 0; + const total_burned = 0; + + await sql` + INSERT INTO supply_changes ( + rune_id, block_height, minted, total_mints, burned, total_burns, total_operations + ) + VALUES ( + + ${rune_id}, ${block_height}, ${minted || 0}, ${ + total_mints || 0 + }, ${burned}, ${total_burned}, ${total_operations || 0} + ) + `; + }); +} + +export async function insertRune(db: PgStore, payload: DbRune): Promise { + await db.sqlWriteTransaction(async sql => { + const { + id, + name, + spaced_name, + number, + block_hash, + block_height, + tx_index, + tx_id, + symbol, + cenotaph, + terms_amount, + terms_cap, + terms_height_start, + terms_height_end, + } = payload; + + await sql` + INSERT INTO runes ( + id, number, name, spaced_name, block_hash, block_height, tx_index, tx_id, symbol, cenotaph, + terms_amount, terms_cap, terms_height_start, terms_height_end, timestamp + ) + VALUES ( + + ${id}, ${number}, ${name}, ${spaced_name}, ${block_hash}, ${block_height}, ${tx_index}, ${tx_id}, ${symbol}, ${cenotaph}, ${ + terms_amount || '' + }, ${terms_cap || ''}, ${terms_height_start}, ${terms_height_end}, 0 + ) + `; + }); +}