test: e2e coverage for mint management and inter-mint transfer

This commit is contained in:
2026-08-19 22:27:14 +02:00
parent f631c7c264
commit d2fc34ed25
3 changed files with 331 additions and 11 deletions
+92 -11
View File
@@ -5,13 +5,18 @@ import type { Page, Route } from '@playwright/test';
// never touch the network. Protocol shapes mirror lnurlcash-kit's client:
// the informational GET on the note URL (fetchNoteInfo) expects an LUD-03
// withdrawRequest echoing the queried k1, or the LNURL ERROR envelope; the
// rotation GET on the callback (rotateNote) expects {status: "OK"}.
// rotation GET on the callback (rotateNote) expects {status: "OK"}. The same
// callback shape also answers a melt (meltNote sends k1 + pr to the same
// callback and only requires status "OK").
//
// Every method takes an optional origin, so a spec can stand up a SECOND
// mint (e.g. MINT2_ORIGIN) for inter-mint transfers.
export const MINT_ORIGIN = 'https://mint.test';
export const MINT2_ORIGIN = 'https://mint2.test';
export const NOTE_PATH = '/note';
export const CALLBACK_PATH = '/callback';
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const fulfillJson = async (route: Route, body: unknown): Promise<void> => {
await route.fulfill({
@@ -30,6 +35,23 @@ interface MockNoteInfoOptions {
// when set, the mint answers the ERROR envelope with this reason instead
// (a reason matching /spent/i surfaces the "already been spent" UI)
spentReason?: string;
// the mint's signing pubkey, advertised as mintPubkey in responses
mintPubkey?: string;
}
interface MockTargetMintOptions {
// the mint's signing pubkey (66 hex chars), advertised in the payRequest
// and note responses
mintPubkey: string;
// the invoice this mint hands out (and reports back as settled) - keep it
// amount-less (decodeBolt11AmountMsat returns null) so the kit skips the
// amount cross-check
invoice: string;
// the payment preimage a settled verify reveals - 64 hex chars (it IS the
// claimed note's secret)
preimage: string;
// value of the note a claim mints, in msat
noteAmountMsat: number;
}
export class MintMocker {
@@ -38,9 +60,9 @@ export class MintMocker {
// The note's informational GET: a spec-shaped withdrawRequest, or the
// ERROR envelope for a note the mint considers spent. The kit validates
// that the service echoes back the queried k1, so read it off the request.
async mockNoteInfo(options: MockNoteInfoOptions): Promise<void> {
async mockNoteInfo(options: MockNoteInfoOptions, origin = MINT_ORIGIN): Promise<void> {
await this.page.route(
new RegExp(`^${escapeRegExp(MINT_ORIGIN + NOTE_PATH)}\\?`),
new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`),
async (route: Route) => {
if (options.spentReason !== undefined) {
await fulfillJson(route, { status: 'ERROR', reason: options.spentReason });
@@ -49,25 +71,84 @@ export class MintMocker {
const k1 = new URL(route.request().url()).searchParams.get('k1') ?? '';
await fulfillJson(route, {
tag: 'withdrawRequest',
callback: `${MINT_ORIGIN}${CALLBACK_PATH}`,
callback: `${origin}${CALLBACK_PATH}`,
k1,
minWithdrawable: options.amountMsat ?? 0,
maxWithdrawable: options.amountMsat ?? 0,
defaultDescription: 'mock mint note',
...(options.mintPubkey ? { mintPubkey: options.mintPubkey } : {}),
});
},
);
}
// The rotation callback GET (k1 + h params): confirm with the OK envelope.
// No sig is returned - a plain LUD-03-style service that speaks rotate but
// does not sign notes.
async mockRotateOk(): Promise<void> {
// The mutating callback GET: rotate (k1 + h params) and melt (k1 + pr
// params) both expect a plain {status: "OK"} confirmation. No sig is
// returned - a plain LUD-03-style service that does not sign notes.
async mockRotateOk(origin = MINT_ORIGIN): Promise<void> {
await this.page.route(
new RegExp(`^${escapeRegExp(MINT_ORIGIN + CALLBACK_PATH)}\\?`),
new RegExp(`^${escapeRegExp(origin + CALLBACK_PATH)}\\?`),
async (route: Route) => {
await fulfillJson(route, { status: 'OK' });
},
);
}
// A mint that never answers its mint-address discovery endpoint with
// anything usable - prepareMint treats that as "no mint-address support"
// and falls back to the plain LNURL-pay guess.
private async mockNoMintAddress(origin: string): Promise<void> {
await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlw/`)}`),
async (route: Route) => {
await fulfillJson(route, { status: 'ERROR', reason: 'not supported' });
},
);
}
// Everything the target side of a transfer (or a Lightning receive)
// needs: the payRequest at the standard mint@ address, the invoice
// callback, an immediately-settled verify endpoint revealing the
// preimage, and the note info + rotate the claim then performs.
async mockTargetMint(options: MockTargetMintOptions, origin = MINT2_ORIGIN): Promise<void> {
await this.mockNoMintAddress(origin);
await this.mockNoteInfo(
{ amountMsat: options.noteAmountMsat, mintPubkey: options.mintPubkey },
origin,
);
await this.mockRotateOk(origin);
await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlp/`)}`),
async (route: Route) => {
await fulfillJson(route, {
tag: 'payRequest',
callback: `${origin}/pay`,
minSendable: 1000,
maxSendable: 100_000_000_000,
withdrawLink: `${origin}${NOTE_PATH}`,
mintPubkey: options.mintPubkey,
metadata: '[]',
});
},
);
await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/pay`)}\\?`),
async (route: Route) => {
await fulfillJson(route, {
pr: options.invoice,
verify: `${origin}/verify`,
});
},
);
await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/verify`)}`),
async (route: Route) => {
await fulfillJson(route, {
settled: true,
preimage: options.preimage,
pr: options.invoice,
});
},
);
}
}