diff --git a/lib/app.js b/lib/app.js index 091c2f6..48bad58 100644 --- a/lib/app.js +++ b/lib/app.js @@ -3,6 +3,7 @@ import Fastify from 'fastify' import cors from '@fastify/cors' import pg from '@fastify/postgres' import { subnetRoutes } from './subnet-routes.js' +import { roundRoutes } from './round-routes.js' /** * @param {object} args @@ -30,6 +31,7 @@ export const createApp = ({ databaseUrl, dbPoolConfig, logger }) => { return 'OK' }) + app.register(roundRoutes) app.register(subnetRoutes) return app } diff --git a/lib/round-routes.js b/lib/round-routes.js new file mode 100644 index 0000000..a687e8e --- /dev/null +++ b/lib/round-routes.js @@ -0,0 +1,156 @@ +/** @import { RequestWithRoundId } from './typings.js' */ +/** @import { FastifyInstance, FastifyReply } from 'fastify' */ + +const roundResponse = { + 200: { + type: 'object', + properties: { + id: { + type: 'number', + format: 'bigint' + }, + start_time: { + type: 'string', + format: 'datetime' + }, + end_time: { + type: 'string', + format: 'datetime' + }, + active: { + type: 'boolean' + }, + tasks: { + type: 'array', + items: { + type: 'object', + properties: { + subnet: { + type: 'string', + pattern: '^(walrus|arweave)$' + }, + task_definition: { + type: 'object', + additionalProperties: true + } + } + } + } + } + } +} + +/** + * Define the round routes + * @param {FastifyInstance} app + */ +export const roundRoutes = (app) => { + app.get( + '/rounds/current', + { + schema: { + response: roundResponse + } + }, + /** + * @param {RequestWithRoundId} request + * @param {FastifyReply} reply + */ + async (request, reply) => { + const client = await app.pg.connect() + try { + const { rows } = await client.query( + `SELECT + cr.*, + CASE + WHEN COUNT(cst.*) > 0 THEN json_agg( + json_build_object( + 'subnet', cst.subnet, + 'task_definition', cst.task_definition + ) + ) + ELSE '[]'::json + END AS tasks + FROM + checker_rounds cr + LEFT JOIN + checker_subnet_tasks cst ON cr.id = cst.round_id + WHERE + cr.active = true + GROUP BY + cr.id + ORDER BY + cr.id DESC + LIMIT 1` + ) + + if (!rows || rows.length === 0) { + reply.status(404) + return + } + + reply.send(rows[0]) + } finally { + client.release() + } + }) + + app.get( + '/rounds/:roundId', + { + schema: { + params: { + type: 'object', + additionalProperties: false, + properties: { + roundId: { + type: 'number' + } + }, + required: ['roundId'] + }, + response: roundResponse + } + }, + /** + * @param {RequestWithRoundId} request + * @param {FastifyReply} reply + */ + async (request, reply) => { + const client = await app.pg.connect() + try { + const roundId = Number(request.params.roundId) + const { rows } = await client.query( + `SELECT + cr.*, + CASE + WHEN COUNT(cst.*) > 0 THEN json_agg( + json_build_object( + 'subnet', cst.subnet, + 'task_definition', cst.task_definition + ) + ) + ELSE '[]'::json + END AS tasks + FROM + checker_rounds cr + LEFT JOIN + checker_subnet_tasks cst ON cr.id = cst.round_id + WHERE + cr.id = $1 + GROUP BY + cr.id`, + [roundId] + ) + + if (!rows || rows.length === 0) { + reply.status(404) + return + } + + reply.send(rows[0]) + } finally { + client.release() + } + }) +} diff --git a/lib/typings.d.ts b/lib/typings.d.ts index 2e18246..bbe9567 100644 --- a/lib/typings.d.ts +++ b/lib/typings.d.ts @@ -9,6 +9,10 @@ export type RequestWithSubnet
= FastifyRequest<{ Querystring: UQuery; }>; +export type RequestWithRoundId = FastifyRequest<{ + Params: { roundId: string }; +}>; + export interface Logger { info: typeof console.info; error: typeof console.error; diff --git a/test/round-and-tasking-service.test.js b/test/round-and-tasking-service.test.js index 842a3e9..4578d00 100644 --- a/test/round-and-tasking-service.test.js +++ b/test/round-and-tasking-service.test.js @@ -2,10 +2,11 @@ import assert from 'assert' import { after, before, beforeEach, describe, it } from 'node:test' import { createPgPool } from '../lib/pool.js' import { migrateWithPgClient } from '../lib/migrate.js' -import { DATABASE_URL } from '../lib/config.js' +import { DATABASE_URL, poolConfig } from '../lib/config.js' import { RoundService } from '../lib/round-service.js' import { TaskingService } from '../lib/tasking-service.js' -import { withRound } from './test-helpers.js' +import { createApp } from '../lib/app.js' +import { withRound, withSubnetTasks } from './test-helpers.js' const DEFAULT_CONFIG = { roundDurationMs: 1000, @@ -15,13 +16,31 @@ const DEFAULT_CONFIG = { describe('round and tasking service', () => { /** @type {import('pg').Pool} */ let pgPool + /** @type {import('fastify').FastifyInstance} */ + let app + /** @type {string} */ + let baseUrl before(async () => { pgPool = await createPgPool(DATABASE_URL) await migrateWithPgClient(pgPool) + + app = createApp({ + databaseUrl: DATABASE_URL, + dbPoolConfig: poolConfig, + logger: { + level: + process.env.DEBUG === '*' || process.env.DEBUG?.includes('test') + ? 'debug' + : 'error' + } + }) + + baseUrl = await app.listen() }) after(async () => { + await app.close() await pgPool.end() }) @@ -174,4 +193,80 @@ describe('round and tasking service', () => { }) }) }) + + describe('round API routes', () => { + describe('GET /rounds/current', () => { + it('should return the current active round with tasks', async () => { + const round = await withRound({ + pgPool, + roundDurationMs: 1000, // 1 second duration + active: true + }) + await withSubnetTasks(pgPool, round.id, 'walrus', { key: 'value' }) + + /** @type {any} */ + const response = await fetch(`${baseUrl}/rounds/current`) + assert.strictEqual(response.status, 200) + const responseBody = await response.json() + assert.equal(responseBody.id, round.id) + assert.strictEqual(responseBody.active, true) + assert.strictEqual(responseBody.tasks.length, 1) + assert.deepStrictEqual(responseBody.tasks, [{ + subnet: 'walrus', + task_definition: { key: 'value' } + }]) + }) + + it('should return 404 if no current active', async () => { + /** @type {any} */ + const response = await fetch(`${baseUrl}/rounds/current`) + assert.strictEqual(response.status, 404) + + // insert inactive round + await withRound({ + pgPool, + roundDurationMs: 1000, // 1 second duration + active: false + }) + + /** @type {any} */ + const secondResponse = await fetch(`${baseUrl}/rounds/current`) + assert.strictEqual(secondResponse.status, 404) + }) + }) + + describe('GET /rounds/:roundId', () => { + it('should return the round with the specified ID and its tasks', async () => { + const round = await withRound({ + pgPool, + roundDurationMs: 1000, // 1 second duration + active: false + }) + await withSubnetTasks(pgPool, round.id, 'arweave', { key: 'value' }) + + /** @type {any} */ + const response = await fetch(`${baseUrl}/rounds/${round.id}`) + assert.strictEqual(response.status, 200) + const responseBody = await response.json() + assert.equal(responseBody.id, round.id) + assert.strictEqual(responseBody.active, false) + assert.strictEqual(responseBody.tasks.length, 1) + assert.deepStrictEqual(responseBody.tasks, [{ + subnet: 'arweave', + task_definition: { key: 'value' } + }]) + }) + + it('should return 400 if roundId is not a number', async () => { + const response = await fetch(`${baseUrl}/rounds/invalid`) + assert.strictEqual(response.status, 400) + }) + + it('should return 404 if round is not found', async () => { + /** @type {any} */ + const response = await fetch(`${baseUrl}/rounds/999999`) + assert.strictEqual(response.status, 404) + }) + }) + }) }) diff --git a/test/test-helpers.js b/test/test-helpers.js index 78b74d6..b597af4 100644 --- a/test/test-helpers.js +++ b/test/test-helpers.js @@ -67,3 +67,16 @@ export const withRound = async ({ pgPool, roundDurationMs, active = false }) => return rows[0] } + +/** + * + * @param {import('../lib/typings.js').PgPool} pgPool + * @param {string} roundId + * @param {string} subnet + * @param {object} task + */ +export const withSubnetTasks = async (pgPool, roundId, subnet, task) => { + await pgPool.query(` + INSERT INTO checker_subnet_tasks (round_id, subnet, task_definition) + VALUES ($1, $2, $3)`, [roundId, subnet, task]) +}