使用例集
繋ぎ屋の実践的な使用例を紹介します。
目次
- 基本的な REQ/EVENT テスト
- イベントの投稿テスト
- 複数リレーのテスト
- フィルターマッチングのテスト
- カスタム REQ ハンドラー
- エラーハンドリングのテスト
- NIP-42 AUTH 処理のテスト
- 大量イベントのテスト
- リアルタイムストリームのテスト
- スレッド・リアクションのテスト
- 不正データ・ログのテスト
- スナップショットを使ったテスト
- 早期キャプチャライブラリへの対応
基本的な REQ/EVENT テスト
基本的な REQ/EVENT テスト:
typescript
import { MockPool } from "@ikuradon/tsunagiya";
import { assertReceivedREQ, EventBuilder } from "@ikuradon/tsunagiya/testing";
import { assertEquals } from "@std/assert";
Deno.test("kind:1 のイベントを取得する", async () => {
const pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");
relay.store(EventBuilder.kind1().content("hello").build());
relay.store(EventBuilder.kind(0).content('{"name":"Alice"}').build());
pool.install();
try {
const events: string[] = [];
const ws = new WebSocket("wss://relay.example.com");
await new Promise<void>((resolve) => {
ws.onopen = () => {
ws.send(JSON.stringify(["REQ", "sub1", { kinds: [1] }]));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg[0] === "EVENT") events.push(msg[2].content);
if (msg[0] === "EOSE") ws.close();
};
ws.onclose = () => resolve();
});
assertEquals(events, ["hello"]); // kind:0 はフィルターされる
assertReceivedREQ(relay, { kinds: [1] });
} finally {
pool.uninstall();
}
});イベントの投稿テスト:
typescript
Deno.test("イベントを投稿して OK を受け取る", async () => {
const pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");
pool.install();
try {
const event = EventBuilder.kind1().content("my post").build();
let okReceived = false;
const ws = new WebSocket("wss://relay.example.com");
await new Promise<void>((resolve) => {
ws.onopen = () => {
ws.send(JSON.stringify(["EVENT", event]));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg[0] === "OK" && msg[1] === event.id && msg[2] === true) {
okReceived = true;
ws.close();
}
};
ws.onclose = () => resolve();
});
assertEquals(okReceived, true);
assertEquals(relay.hasEvent(event.id), true);
} finally {
pool.uninstall();
}
});複数リレーのテスト:
typescript
Deno.test("3つのリレーからイベントを集約する", async () => {
const pool = new MockPool();
const urls = [
"wss://relay1.example.com",
"wss://relay2.example.com",
"wss://relay3.example.com",
];
urls.forEach((url, i) => {
pool.relay(url).store(
EventBuilder.kind1().content(`event from relay ${i + 1}`).build(),
);
});
pool.install();
try {
const allEvents: string[] = [];
let done = 0;
await new Promise<void>((resolve) => {
for (const url of urls) {
const ws = new WebSocket(url);
ws.onopen = () => ws.send(JSON.stringify(["REQ", "s", { kinds: [1] }]));
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg[0] === "EVENT") allEvents.push(msg[2].content);
if (msg[0] === "EOSE") ws.close();
};
ws.onclose = () => {
if (++done === 3) resolve();
};
}
});
assertEquals(allEvents.length, 3);
} finally {
pool.uninstall();
}
});フィルターマッチングのテスト:
typescript
import { filterEvents, matchFilter } from "@ikuradon/tsunagiya";
Deno.test("フィルターマッチング", () => {
const event = EventBuilder.kind1()
.pubkey("alice")
.createdAt(1700000000)
.tag("t", "nostr")
.build();
// kind マッチ
assertEquals(matchFilter(event, { kinds: [1] }), true);
assertEquals(matchFilter(event, { kinds: [0] }), false);
// author マッチ(プレフィックス)
assertEquals(matchFilter(event, { authors: ["alice"] }), true);
// 時間範囲
assertEquals(matchFilter(event, { since: 1699999999 }), true);
assertEquals(matchFilter(event, { since: 1700000001 }), false);
// タグフィルター
assertEquals(matchFilter(event, { "#t": ["nostr"] }), true);
assertEquals(matchFilter(event, { "#t": ["bitcoin"] }), false);
});
Deno.test("filterEvents で limit を適用する", () => {
const events = EventBuilder.timeline(100, { kind: 1 });
const result = filterEvents(events, { kinds: [1], limit: 10 });
assertEquals(result.length, 10);
});カスタム REQ ハンドラー・エラーハンドリング・AUTH
カスタム REQ ハンドラー:
typescript
Deno.test("カスタム REQ ハンドラーで動的にイベントを返す", async () => {
const pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");
relay.onREQ((subId, filters) => {
// フィルターに基づいて動的にイベント生成
const kind = filters[0]?.kinds?.[0] ?? 1;
return [
EventBuilder.kind(kind).content(`dynamic event for ${subId}`).build(),
];
});
pool.install();
try {
const ws = new WebSocket("wss://relay.example.com");
let content = "";
await new Promise<void>((resolve) => {
ws.onopen = () =>
ws.send(JSON.stringify(["REQ", "my-sub", { kinds: [1] }]));
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg[0] === "EVENT") content = msg[2].content;
if (msg[0] === "EOSE") ws.close();
};
ws.onclose = () => resolve();
});
assertEquals(content, "dynamic event for my-sub");
} finally {
pool.uninstall();
}
});エラーハンドリングのテスト:
typescript
Deno.test("未登録URLへの接続は失敗する", async () => {
const pool = new MockPool();
pool.relay("wss://known.relay.test"); // 別のURLだけ登録
pool.install();
try {
const ws = new WebSocket("wss://unknown.relay.test");
let errorFired = false;
const code = await new Promise<number>((resolve) => {
ws.onerror = () => {
errorFired = true;
};
ws.onclose = (e) => resolve(e.code);
});
assertEquals(errorFired, true);
assertEquals(code, 1006);
} finally {
pool.uninstall();
}
});
Deno.test("EVENT が拒否されるケース", async () => {
const pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");
relay.onEVENT((event) => {
return ["OK", event.id, false, "blocked: content policy violation"];
});
pool.install();
try {
const event = EventBuilder.kind1().content("spam").build();
const ws = new WebSocket("wss://relay.example.com");
const result = await new Promise<[boolean, string]>((resolve) => {
ws.onopen = () => ws.send(JSON.stringify(["EVENT", event]));
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg[0] === "OK") {
resolve([msg[2], msg[3]]);
ws.close();
}
};
});
assertEquals(result[0], false);
assertEquals(result[1], "blocked: content policy violation");
} finally {
pool.uninstall();
}
});
Deno.test("NOTICE メッセージの受信", async () => {
const pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");
pool.install();
try {
const ws = new WebSocket("wss://relay.example.com");
let notice = "";
await new Promise<void>((resolve) => {
ws.onopen = () => {
relay.sendNotice("rate-limited: slow down");
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg[0] === "NOTICE") {
notice = msg[1];
ws.close();
}
};
ws.onclose = () => resolve();
});
assertEquals(notice, "rate-limited: slow down");
} finally {
pool.uninstall();
}
});NIP-42 AUTH 処理のテスト:
typescript
Deno.test("AUTH チャレンジ/レスポンス", async () => {
const pool = new MockPool();
const relay = pool.relay("wss://auth.relay.test", { requiresAuth: true });
relay.requireAuth((authEvent, context) => {
return authEvent.tags.some(
(t) => t[0] === "relay" && t[1] === context.relayUrl,
);
});
pool.install();
try {
const ws = new WebSocket("wss://auth.relay.test");
let authResult = false;
await new Promise<void>((resolve) => {
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg[0] === "AUTH") {
const challenge = msg[1];
const authEvent = EventBuilder.kind(22242)
.tag("relay", "wss://auth.relay.test")
.tag("challenge", challenge)
.build();
ws.send(JSON.stringify(["AUTH", authEvent]));
}
if (msg[0] === "OK") {
authResult = msg[2];
ws.close();
}
};
ws.onclose = () => resolve();
});
assertEquals(authResult, true);
} finally {
pool.uninstall();
}
});大量イベントのテスト:
typescript
Deno.test("1000件のイベントを処理する", async () => {
const pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");
const events = EventBuilder.bulk(1000, { kind: 1 });
for (const e of events) relay.store(e);
pool.install();
try {
const received: unknown[] = [];
const ws = new WebSocket("wss://relay.example.com");
await new Promise<void>((resolve) => {
ws.onopen = () => ws.send(JSON.stringify(["REQ", "s", { kinds: [1] }]));
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg[0] === "EVENT") received.push(msg[2]);
if (msg[0] === "EOSE") ws.close();
};
ws.onclose = () => resolve();
});
assertEquals(received.length, 1000);
} finally {
pool.uninstall();
}
});ストリーム・スレッド・リアクション・スナップショット
リアルタイムストリームのテスト:
typescript
import { startStream, streamEvents } from "@ikuradon/tsunagiya/testing";
Deno.test("時間差でイベントが配信される", async () => {
const pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");
const events = EventBuilder.bulk(5, { kind: 1 });
pool.install();
try {
const ws = new WebSocket("wss://relay.example.com");
const received: unknown[] = [];
await new Promise<void>((resolve) => {
ws.onopen = () => {
ws.send(JSON.stringify(["REQ", "stream", { kinds: [1] }]));
const handle = streamEvents(relay, events, { interval: 100 });
setTimeout(() => {
handle.stop();
ws.close();
}, 700);
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg[0] === "EVENT") received.push(msg[2]);
};
ws.onclose = () => resolve();
});
assertEquals(received.length >= 3, true);
} finally {
pool.uninstall();
}
});
Deno.test("継続的ストリーム", async () => {
const pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");
pool.install();
try {
const ws = new WebSocket("wss://relay.example.com");
const received: unknown[] = [];
await new Promise<void>((resolve) => {
ws.onopen = () => {
ws.send(JSON.stringify(["REQ", "live", { kinds: [1] }]));
const stream = startStream(relay, {
eventGenerator: () => EventBuilder.random({ kind: 1 }),
interval: 50,
count: 5,
});
setTimeout(() => {
stream.stop();
ws.close();
}, 500);
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg[0] === "EVENT") received.push(msg[2]);
};
ws.onclose = () => resolve();
});
assertEquals(received.length, 5);
} finally {
pool.uninstall();
}
});スレッドとリアクションのテスト:
typescript
Deno.test("スレッドの取得", async () => {
const pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");
const thread = EventBuilder.thread(5);
for (const e of thread) relay.store(e);
pool.install();
try {
const ws = new WebSocket("wss://relay.example.com");
const replies: unknown[] = [];
await new Promise<void>((resolve) => {
ws.onopen = () => {
ws.send(JSON.stringify(["REQ", "thread", { "#e": [thread[0].id] }]));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg[0] === "EVENT") replies.push(msg[2]);
if (msg[0] === "EOSE") ws.close();
};
ws.onclose = () => resolve();
});
assertEquals(replies.length, 4);
} finally {
pool.uninstall();
}
});
Deno.test("リアクションの取得", async () => {
const pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");
const [post, reactions] = EventBuilder.withReactions(10);
relay.store(post);
for (const r of reactions) relay.store(r);
pool.install();
try {
const ws = new WebSocket("wss://relay.example.com");
const received: unknown[] = [];
await new Promise<void>((resolve) => {
ws.onopen = () => {
ws.send(
JSON.stringify(["REQ", "reactions", { kinds: [7], "#e": [post.id] }]),
);
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg[0] === "EVENT") received.push(msg[2]);
if (msg[0] === "EOSE") ws.close();
};
ws.onclose = () => resolve();
});
assertEquals(received.length, 10);
} finally {
pool.uninstall();
}
});不正データとログのテスト:
typescript
Deno.test("不正JSONの受信", async () => {
const pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");
pool.install();
try {
const ws = new WebSocket("wss://relay.example.com");
const messages: string[] = [];
await new Promise<void>((resolve) => {
ws.onopen = () => {
relay.sendRaw("this is not json");
relay.sendRaw('{"also": "not a nostr message"}');
setTimeout(() => ws.close(), 100);
};
ws.onmessage = (e) => messages.push(e.data);
ws.onclose = () => resolve();
});
assertEquals(messages.length, 2);
} finally {
pool.uninstall();
}
});
Deno.test("カスタムログハンドラー", async () => {
const pool = new MockPool();
const logs: LogEntry[] = [];
pool.relay("wss://relay.example.com", {
logging: (entry) => logs.push(entry),
});
pool.install();
try {
const ws = new WebSocket("wss://relay.example.com");
await new Promise<void>((resolve) => {
ws.onopen = () => {
ws.send(JSON.stringify(["REQ", "s", { kinds: [1] }]));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg[0] === "EOSE") ws.close();
};
ws.onclose = () => resolve();
});
const receives = logs.filter((l) => l.direction === "receive");
const sends = logs.filter((l) => l.direction === "send");
assertEquals(receives.length >= 1, true);
assertEquals(sends.length >= 1, true);
} finally {
pool.uninstall();
}
});スナップショットを使ったテスト:
typescript
import { restore, snapshot } from "@ikuradon/tsunagiya/testing";
Deno.test("スナップショットで複数テストケースを効率的に実行", () => {
const pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");
const baseEvents = EventBuilder.bulk(10, { kind: 1 });
for (const e of baseEvents) relay.store(e);
const baseline = snapshot(relay);
relay.store(EventBuilder.kind1().content("extra").build());
// ... 検証 ...
restore(relay, baseline);
relay.store(EventBuilder.kind(7).content("+").build());
// ... 検証 ...
restore(relay, baseline);
});早期キャプチャライブラリへの対応
一部の Nostr クライアントライブラリ(NDK など)は、モジュールのロード時に globalThis.WebSocket への参照をキャプチャします。このため、通常の pool.install() では MockWebSocket を使ってもらえないことがあります。
このような「早期キャプチャ」が起こるライブラリをテストするには、 ブートストラップパターンを使います。
なぜ通常の方法では動かないのか
typescript
// ❌ これは動かない(NDK はモジュールロード時に WebSocket を捕捉済み)
import NDK from "@nostr-dev-kit/ndk";
const pool = new MockPool();
pool.install();
// NDK はすでに実際の WebSocket を参照しているため、MockWebSocket を使わないブートストラップパターン
pool.install() を先に行い、その後でライブラリを dynamic import することで、 ライブラリのモジュールロード時に MockWebSocket をキャプチャさせます。
NDK ブートストラップパターン(日本語):
typescript
import { MockPool } from "@ikuradon/tsunagiya";
// 1. MockPool を先にインストール
const bootstrap = new MockPool();
bootstrap.relay("wss://bootstrap");
bootstrap.install();
// 2. NDK を dynamic import(この時点で MockWebSocket を捕捉する)
const { default: NDK } = await import("@nostr-dev-kit/ndk");
// 3. ブートストラップ用の MockPool をアンインストール
bootstrap.uninstall();
// 以降は通常通りテストを記述する
Deno.test("NDK でイベントを取得する", async () => {
const pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");
relay.store(EventBuilder.kind1().content("hello from NDK").build());
pool.install();
try {
const ndk = new NDK({ explicitRelayUrls: ["wss://relay.example.com"] });
await ndk.connect();
const events = await ndk.fetchEvents({ kinds: [1] });
assertEquals([...events].length, 1);
} finally {
pool.uninstall();
}
});実際のテストファイルでの適用
実際のプロジェクトでは、テストファイルのトップレベルでブートストラップを行い、 テスト関数内では通常の MockPool を使います。
typescript
// test_file.ts
import { MockPool } from "@ikuradon/tsunagiya";
// ファイルのトップレベルでブートストラップ(一度だけ実行)
const _bootstrap = new MockPool();
_bootstrap.relay("wss://bootstrap");
_bootstrap.install();
// NDK を dynamic import(MockWebSocket を捕捉する)
const client = await import("./client.ts"); // NDK をインポートするモジュール
_bootstrap.uninstall();
// 各テストでは通常通り MockPool を使う
Deno.test("timeline を取得する", async () => {
const pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");
relay.store(EventBuilder.kind1().content("hello").build());
pool.install();
try {
const events = await client.timeline(["wss://relay.example.com"]);
assertEquals(events.length, 1);
} finally {
pool.uninstall();
}
});注意: ブートストラップはテストファイルのトップレベルで一度だけ 実行します。各テスト関数内で繰り返さないでください。
関連ドキュメント
- API リファレンス — 全 API の詳細
- テストパターン — テストパターン集
- ベストプラクティス — テスト設計のベストプラクティス