175 lines
5.4 KiB
TypeScript
175 lines
5.4 KiB
TypeScript
/**
|
|
* Availability endpoint test.
|
|
*
|
|
* Approach: construct a Hono app wired exactly like main.ts but with
|
|
* a stub BlurtClient injected. We don't need a real chain — just a
|
|
* spy that returns what the test expects.
|
|
*/
|
|
|
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
import { Hono } from 'hono';
|
|
|
|
import { registerAvailabilityRoutes } from '../src/api/availability.ts';
|
|
import { Limiter } from '../src/middleware/ratelimit.ts';
|
|
import type { BlurtClient, AccountInfo } from '../src/blurt/client.ts';
|
|
|
|
interface AvailabilityResponse {
|
|
name?: string;
|
|
available?: boolean;
|
|
reason?: string;
|
|
status?: string;
|
|
code?: string;
|
|
message?: string;
|
|
}
|
|
|
|
interface StubBlurt {
|
|
client: BlurtClient;
|
|
getAccount: ReturnType<typeof vi.fn>;
|
|
}
|
|
|
|
function makeStubBlurt(accountResponse: AccountInfo | null | Error): StubBlurt {
|
|
const getAccount = vi.fn(async () => {
|
|
if (accountResponse instanceof Error) throw accountResponse;
|
|
return accountResponse;
|
|
});
|
|
// Cast through unknown: we only exercise getAccount() in these tests.
|
|
const client = { getAccount } as unknown as BlurtClient;
|
|
return { client, getAccount };
|
|
}
|
|
|
|
function makeApp(stub: StubBlurt, maxPerMin = 1000): { app: Hono; limiter: Limiter } {
|
|
const app = new Hono();
|
|
const limiter = new Limiter(maxPerMin, 60_000);
|
|
registerAvailabilityRoutes(app, stub.client, limiter);
|
|
return { app, limiter };
|
|
}
|
|
|
|
async function post(
|
|
app: Hono,
|
|
body: unknown
|
|
): Promise<{ status: number; body: AvailabilityResponse }> {
|
|
const res = await app.request('/v1/account/availability', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: typeof body === 'string' ? body : JSON.stringify(body)
|
|
});
|
|
const json = (await res.json().catch(() => ({}))) as AvailabilityResponse;
|
|
return { status: res.status, body: json };
|
|
}
|
|
|
|
describe('POST /v1/account/availability', () => {
|
|
let limiters: Limiter[] = [];
|
|
|
|
beforeEach(() => {
|
|
limiters = [];
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const l of limiters) l.close();
|
|
});
|
|
|
|
it('returns available=true when the name passes structural + chain check', async () => {
|
|
const stub = makeStubBlurt(null); // name not found on-chain
|
|
const { app, limiter } = makeApp(stub);
|
|
limiters.push(limiter);
|
|
|
|
const { status, body } = await post(app, { name: 'sally' });
|
|
expect(status).toBe(200);
|
|
expect(body.available).toBe(true);
|
|
expect(body.name).toBe('sally');
|
|
expect(body.reason).toBeUndefined();
|
|
expect(stub.getAccount).toHaveBeenCalledWith('sally');
|
|
});
|
|
|
|
it('returns available=false with reason=already_registered when on-chain', async () => {
|
|
const stub = makeStubBlurt({
|
|
name: 'taken',
|
|
created: '2024-01-01T00:00:00',
|
|
balance: '0.000 BLURT',
|
|
pending_claimed_accounts: 0,
|
|
posting_pubkey: undefined
|
|
});
|
|
const { app, limiter } = makeApp(stub);
|
|
limiters.push(limiter);
|
|
|
|
const { body } = await post(app, { name: 'taken' });
|
|
expect(body.available).toBe(false);
|
|
expect(body.reason).toBe('already_registered');
|
|
});
|
|
|
|
it('rejects structurally-invalid names WITHOUT calling the chain', async () => {
|
|
const stub = makeStubBlurt(null);
|
|
const { app, limiter } = makeApp(stub);
|
|
limiters.push(limiter);
|
|
|
|
const cases: Array<[string, string]> = [
|
|
['ab', 'too_short'],
|
|
['abcdefghijklmnopq', 'too_long'],
|
|
['3sally', 'must_start_with_letter'],
|
|
['sally-', 'leading_trailing_dash'],
|
|
['sa--lly', 'consecutive_dashes'],
|
|
['sally.x', 'dotted_not_allowed'],
|
|
['sal ly', 'bad_chars'],
|
|
['morphit', 'reserved']
|
|
];
|
|
for (const [name, expectedReason] of cases) {
|
|
const { body } = await post(app, { name });
|
|
expect(body.available, `for ${name}`).toBe(false);
|
|
expect(body.reason, `for ${name}`).toBe(expectedReason);
|
|
}
|
|
expect(stub.getAccount).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('normalizes to lowercase + trims before validation', async () => {
|
|
const stub = makeStubBlurt(null);
|
|
const { app, limiter } = makeApp(stub);
|
|
limiters.push(limiter);
|
|
|
|
const { body } = await post(app, { name: ' SALLY ' });
|
|
expect(body.name).toBe('sally');
|
|
expect(body.available).toBe(true);
|
|
expect(stub.getAccount).toHaveBeenCalledWith('sally');
|
|
});
|
|
|
|
it('returns 503 chain_unavailable when the chain throws', async () => {
|
|
const stub = makeStubBlurt(new Error('ECONNREFUSED'));
|
|
const { app, limiter } = makeApp(stub);
|
|
limiters.push(limiter);
|
|
|
|
const { status, body } = await post(app, { name: 'sally' });
|
|
expect(status).toBe(503);
|
|
expect(body.code).toBe('chain_unavailable');
|
|
});
|
|
|
|
it('rejects malformed JSON with 400 malformed_request', async () => {
|
|
const stub = makeStubBlurt(null);
|
|
const { app, limiter } = makeApp(stub);
|
|
limiters.push(limiter);
|
|
|
|
const { status, body } = await post(app, 'not json');
|
|
expect(status).toBe(400);
|
|
expect(body.code).toBe('malformed_request');
|
|
});
|
|
|
|
it('rejects unknown fields in the body', async () => {
|
|
const stub = makeStubBlurt(null);
|
|
const { app, limiter } = makeApp(stub);
|
|
limiters.push(limiter);
|
|
|
|
const { status, body } = await post(app, { name: 'sally', extra: 'nope' });
|
|
expect(status).toBe(400);
|
|
expect(body.code).toBe('malformed_request');
|
|
});
|
|
|
|
it('rate-limits after the bucket fills', async () => {
|
|
const stub = makeStubBlurt(null);
|
|
const { app, limiter } = makeApp(stub, 2);
|
|
limiters.push(limiter);
|
|
|
|
await post(app, { name: 'one' });
|
|
await post(app, { name: 'two' });
|
|
const { status, body } = await post(app, { name: 'three' });
|
|
expect(status).toBe(429);
|
|
expect(body.code).toBe('rate_limited');
|
|
});
|
|
});
|