@yaebal/test 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +400 -15
  2. package/lib/api.d.ts +83 -0
  3. package/lib/api.d.ts.map +1 -0
  4. package/lib/api.js +186 -0
  5. package/lib/api.js.map +1 -0
  6. package/lib/api.test.d.ts +2 -0
  7. package/lib/api.test.d.ts.map +1 -0
  8. package/lib/api.test.js +131 -0
  9. package/lib/api.test.js.map +1 -0
  10. package/lib/bot-messages.d.ts +42 -0
  11. package/lib/bot-messages.d.ts.map +1 -0
  12. package/lib/bot-messages.js +72 -0
  13. package/lib/bot-messages.js.map +1 -0
  14. package/lib/chat-actor.d.ts +45 -0
  15. package/lib/chat-actor.d.ts.map +1 -0
  16. package/lib/chat-actor.js +72 -0
  17. package/lib/chat-actor.js.map +1 -0
  18. package/lib/clock.d.ts +22 -0
  19. package/lib/clock.d.ts.map +1 -0
  20. package/lib/clock.js +72 -0
  21. package/lib/clock.js.map +1 -0
  22. package/lib/clock.test.d.ts +2 -0
  23. package/lib/clock.test.d.ts.map +1 -0
  24. package/lib/clock.test.js +69 -0
  25. package/lib/clock.test.js.map +1 -0
  26. package/lib/env.d.ts +100 -0
  27. package/lib/env.d.ts.map +1 -0
  28. package/lib/env.js +164 -0
  29. package/lib/env.js.map +1 -0
  30. package/lib/env.test.d.ts +2 -0
  31. package/lib/env.test.d.ts.map +1 -0
  32. package/lib/env.test.js +302 -0
  33. package/lib/env.test.js.map +1 -0
  34. package/lib/fetch.d.ts +3 -0
  35. package/lib/fetch.d.ts.map +1 -0
  36. package/lib/fetch.js +12 -0
  37. package/lib/fetch.js.map +1 -0
  38. package/lib/index.d.ts +19 -52
  39. package/lib/index.d.ts.map +1 -1
  40. package/lib/index.js +19 -115
  41. package/lib/index.js.map +1 -1
  42. package/lib/internal.d.ts +25 -0
  43. package/lib/internal.d.ts.map +1 -0
  44. package/lib/internal.js +19 -0
  45. package/lib/internal.js.map +1 -0
  46. package/lib/keyboard.d.ts +15 -0
  47. package/lib/keyboard.d.ts.map +1 -0
  48. package/lib/keyboard.js +25 -0
  49. package/lib/keyboard.js.map +1 -0
  50. package/lib/keyboard.test.d.ts +2 -0
  51. package/lib/keyboard.test.d.ts.map +1 -0
  52. package/lib/keyboard.test.js +31 -0
  53. package/lib/keyboard.test.js.map +1 -0
  54. package/lib/normalize.d.ts +10 -0
  55. package/lib/normalize.d.ts.map +1 -0
  56. package/lib/normalize.js +27 -0
  57. package/lib/normalize.js.map +1 -0
  58. package/lib/reactions.d.ts +3 -0
  59. package/lib/reactions.d.ts.map +1 -0
  60. package/lib/reactions.js +17 -0
  61. package/lib/reactions.js.map +1 -0
  62. package/lib/updates.d.ts +126 -0
  63. package/lib/updates.d.ts.map +1 -0
  64. package/lib/updates.js +200 -0
  65. package/lib/updates.js.map +1 -0
  66. package/lib/updates.test.d.ts +2 -0
  67. package/lib/updates.test.d.ts.map +1 -0
  68. package/lib/updates.test.js +72 -0
  69. package/lib/updates.test.js.map +1 -0
  70. package/lib/user-actor.d.ts +188 -0
  71. package/lib/user-actor.d.ts.map +1 -0
  72. package/lib/user-actor.js +465 -0
  73. package/lib/user-actor.js.map +1 -0
  74. package/lib/webhook.d.ts +18 -0
  75. package/lib/webhook.d.ts.map +1 -0
  76. package/lib/webhook.js +25 -0
  77. package/lib/webhook.js.map +1 -0
  78. package/lib/webhook.test.d.ts +2 -0
  79. package/lib/webhook.test.d.ts.map +1 -0
  80. package/lib/webhook.test.js +36 -0
  81. package/lib/webhook.test.js.map +1 -0
  82. package/package.json +7 -5
  83. package/src/api.test.ts +180 -0
  84. package/src/api.ts +289 -0
  85. package/src/bot-messages.ts +117 -0
  86. package/src/chat-actor.ts +101 -0
  87. package/src/clock.test.ts +80 -0
  88. package/src/clock.ts +118 -0
  89. package/src/env.test.ts +370 -0
  90. package/src/env.ts +235 -0
  91. package/src/fetch.ts +11 -0
  92. package/src/index.ts +79 -169
  93. package/src/internal.ts +43 -0
  94. package/src/keyboard.test.ts +35 -0
  95. package/src/keyboard.ts +38 -0
  96. package/src/normalize.ts +34 -0
  97. package/src/reactions.ts +18 -0
  98. package/src/updates.test.ts +107 -0
  99. package/src/updates.ts +354 -0
  100. package/src/user-actor.ts +702 -0
  101. package/src/webhook.test.ts +54 -0
  102. package/src/webhook.ts +48 -0
  103. package/lib/index.test.d.ts +0 -2
  104. package/lib/index.test.d.ts.map +0 -1
  105. package/lib/index.test.js +0 -66
  106. package/lib/index.test.js.map +0 -1
  107. package/src/index.test.ts +0 -101
@@ -0,0 +1,117 @@
1
+ import type { Api, Chat, Message } from "@yaebal/core";
2
+
3
+ /** `Message`'s own `reply_markup` union — reused so a `BotMessage` structurally satisfies `Message`. */
4
+ type ReplyMarkup = NonNullable<Message["reply_markup"]>;
5
+
6
+ /**
7
+ * a live mirror of one message the bot sent — built from the outgoing `send*` params (so it's
8
+ * populated even against the builtin `{ message_id }` stub, no `onApi` override needed) and kept
9
+ * in sync in place as `editMessageText`/`editMessageCaption`/`editMessageReplyMarkup` calls land,
10
+ * so a reference captured before an edit still reflects it after.
11
+ */
12
+ export interface BotMessage {
13
+ message_id: number;
14
+ chat: { id: number; type: Chat["type"] };
15
+ text?: string;
16
+ caption?: string;
17
+ reply_markup?: ReplyMarkup;
18
+ date: number;
19
+ }
20
+
21
+ export interface LastBotMessageQuery {
22
+ /** scope to a specific chat (an `{ id }`-shaped value — a `ChatActor` or `pmChat` both qualify). */
23
+ chat?: { id: number };
24
+ /** only consider messages currently carrying a non-empty `inline_keyboard`. */
25
+ withReplyMarkup?: boolean;
26
+ /** arbitrary predicate over the call that produced (or last touched) the message. */
27
+ where?: (call: { method: string; params: Record<string, unknown> | undefined }) => boolean;
28
+ }
29
+
30
+ export interface BotMessageTracker {
31
+ lastBotMessage(query?: LastBotMessageQuery): BotMessage | undefined;
32
+ botMessage(chatId: number, messageId: number): BotMessage | undefined;
33
+ clear(): void;
34
+ }
35
+
36
+ const SEND_LIKE = (method: string): boolean =>
37
+ method.startsWith("send") || method === "forwardMessage" || method === "copyMessage";
38
+
39
+ function hasInlineKeyboard(markup: ReplyMarkup | undefined): boolean {
40
+ const withRows = markup as { inline_keyboard?: unknown[] } | undefined;
41
+ const rows = withRows?.inline_keyboard;
42
+ return Array.isArray(rows) && rows.length > 0;
43
+ }
44
+
45
+ /** install bot-message tracking on a mock `api` via its `after` hook. */
46
+ export function attachBotMessageTracking(
47
+ api: Api,
48
+ resolveChatType: (chatId: number) => Chat["type"],
49
+ ): BotMessageTracker {
50
+ interface Entry extends BotMessage {
51
+ lastCall: { method: string; params: Record<string, unknown> | undefined };
52
+ }
53
+
54
+ const byKey = new Map<string, Entry>();
55
+ const order: Entry[] = [];
56
+ const key = (chatId: number, messageId: number): string => `${chatId}:${messageId}`;
57
+
58
+ api.after((method, params, result) => {
59
+ const p = params ?? {};
60
+
61
+ if (method.startsWith("edit")) {
62
+ const chatId = p.chat_id as number | undefined;
63
+ const messageId = p.message_id as number | undefined;
64
+ if (chatId === undefined || messageId === undefined) return;
65
+
66
+ const entry = byKey.get(key(chatId, messageId));
67
+ if (!entry) return;
68
+
69
+ if (method === "editMessageText" && typeof p.text === "string") entry.text = p.text;
70
+ if (method === "editMessageCaption") entry.caption = p.caption as string | undefined;
71
+ if ("reply_markup" in p) entry.reply_markup = p.reply_markup as ReplyMarkup | undefined;
72
+
73
+ entry.lastCall = { method, params: p };
74
+ return;
75
+ }
76
+
77
+ if (!SEND_LIKE(method)) return;
78
+ if (!result || typeof result !== "object" || !("message_id" in result)) return;
79
+
80
+ const chatId = p.chat_id as number | undefined;
81
+ if (chatId === undefined) return;
82
+
83
+ const messageId = (result as { message_id: number }).message_id;
84
+ const entry: Entry = {
85
+ message_id: messageId,
86
+ chat: { id: chatId, type: resolveChatType(chatId) },
87
+ text: p.text as string | undefined,
88
+ caption: p.caption as string | undefined,
89
+ reply_markup: p.reply_markup as ReplyMarkup | undefined,
90
+ date: (result as { date?: number }).date ?? Date.now(),
91
+ lastCall: { method, params: p },
92
+ };
93
+
94
+ byKey.set(key(chatId, messageId), entry);
95
+ order.push(entry);
96
+ });
97
+
98
+ return {
99
+ lastBotMessage(query = {}) {
100
+ for (let i = order.length - 1; i >= 0; i--) {
101
+ const entry = order[i] as Entry;
102
+ if (query.chat && entry.chat.id !== query.chat.id) continue;
103
+ if (query.withReplyMarkup && !hasInlineKeyboard(entry.reply_markup)) continue;
104
+ if (query.where && !query.where(entry.lastCall)) continue;
105
+
106
+ return entry;
107
+ }
108
+
109
+ return undefined;
110
+ },
111
+ botMessage: (chatId, messageId) => byKey.get(key(chatId, messageId)),
112
+ clear() {
113
+ byKey.clear();
114
+ order.length = 0;
115
+ },
116
+ };
117
+ }
@@ -0,0 +1,101 @@
1
+ import type { Chat, Message } from "@yaebal/core";
2
+ import type { ActorHost, SendText } from "./internal.js";
3
+ import { resolveSendText } from "./internal.js";
4
+ import { createUpdate } from "./updates.js";
5
+ import type { UserActor } from "./user-actor.js";
6
+
7
+ export type ChatType = "private" | "group" | "supergroup" | "channel";
8
+
9
+ export interface CreateChatOptions {
10
+ type: ChatType;
11
+ id?: number;
12
+ title?: string;
13
+ username?: string;
14
+ }
15
+
16
+ /** a chat member's tracked status — set via {@link ChatActor.setMembership}. */
17
+ export interface ChatMembership {
18
+ status: string;
19
+ since?: number;
20
+ }
21
+
22
+ let chatIdCounter = -1000;
23
+
24
+ /**
25
+ * a passive container actors send into — a group/supergroup/channel/private chat. tracks who's
26
+ * currently a member (best-effort — nothing enforces it unless you opt into `strictMembership`
27
+ * via {@link createTestEnv}) and every message dispatched through it.
28
+ */
29
+ export class ChatActor {
30
+ readonly id: number;
31
+ readonly type: ChatType;
32
+ readonly title?: string;
33
+ readonly username?: string;
34
+ readonly members = new Set<UserActor>();
35
+ readonly messages: unknown[] = [];
36
+
37
+ private readonly host: ActorHost;
38
+ private readonly membership = new Map<number, ChatMembership>();
39
+
40
+ constructor(host: ActorHost, options: CreateChatOptions) {
41
+ this.host = host;
42
+ this.id = options.id ?? chatIdCounter--;
43
+ this.type = options.type;
44
+ this.title = options.title;
45
+ this.username = options.username;
46
+ }
47
+
48
+ /** reconstruct a `ChatActor` from a raw `Chat` payload seen on an update (e.g. to find the chat a message you didn't create arrived in). not registered with any `TestEnv`. */
49
+ static fromChat(host: ActorHost, chat: Chat): ChatActor {
50
+ return new ChatActor(host, {
51
+ type: chat.type as ChatType,
52
+ id: chat.id,
53
+ title: chat.title,
54
+ username: chat.username,
55
+ });
56
+ }
57
+
58
+ /** this chat as a plain {@link Chat} payload, ready to embed in an update. */
59
+ toChat(): Chat {
60
+ return {
61
+ id: this.id,
62
+ type: this.type,
63
+ ...(this.title !== undefined ? { title: this.title } : {}),
64
+ ...(this.username !== undefined ? { username: this.username } : {}),
65
+ };
66
+ }
67
+
68
+ /** record (or update) a member's status — used by `strictMembership` and `getChatMember`-style assertions. */
69
+ setMembership(userId: number, membership: ChatMembership): void {
70
+ this.membership.set(userId, membership);
71
+ }
72
+
73
+ /** the tracked membership for a user, or `undefined` if never set. */
74
+ membershipOf(user: UserActor): ChatMembership | undefined {
75
+ return this.membership.get(user.id);
76
+ }
77
+
78
+ /**
79
+ * an anonymous channel post — `update.channel_post`, with no `from` (matching real Telegram:
80
+ * channel posts aren't attributed to a user). throws on non-channel chats.
81
+ */
82
+ async post(text: SendText): Promise<Message> {
83
+ if (this.type !== "channel") {
84
+ throw new Error("ChatActor.post(): only channel chats can post — did you mean a user actor?");
85
+ }
86
+
87
+ const { text: resolvedText, entities } = resolveSendText(text);
88
+ const message = {
89
+ message_id: this.host.nextMessageId(),
90
+ date: this.host.now(),
91
+ chat: this.toChat(),
92
+ text: resolvedText,
93
+ ...(entities.length ? { entities } : {}),
94
+ };
95
+
96
+ this.messages.push(message);
97
+ await this.host.dispatch(createUpdate({ channel_post: message }));
98
+
99
+ return message as Message;
100
+ }
101
+ }
@@ -0,0 +1,80 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { installTestClock } from "./clock.js";
4
+
5
+ test("installTestClock overrides Date.now and advances on demand", async () => {
6
+ const clock = installTestClock(1_700_000_000_000);
7
+ try {
8
+ assert.equal(Date.now(), 1_700_000_000_000);
9
+ await clock.advance(5000);
10
+ assert.equal(Date.now(), 1_700_000_005_000);
11
+ } finally {
12
+ clock.restore();
13
+ }
14
+ });
15
+
16
+ test("advance() fires a setTimeout whose deadline falls inside the window", async () => {
17
+ const clock = installTestClock();
18
+ try {
19
+ let fired = false;
20
+ setTimeout(
21
+ () => {
22
+ fired = true;
23
+ },
24
+ 60 * 60 * 1000,
25
+ );
26
+
27
+ await clock.advance(30 * 60 * 1000);
28
+ assert.equal(fired, false);
29
+
30
+ await clock.advance(30 * 60 * 1000);
31
+ assert.equal(fired, true);
32
+ } finally {
33
+ clock.restore();
34
+ }
35
+ });
36
+
37
+ test("advance() re-arms setInterval and may fire it multiple times", async () => {
38
+ const clock = installTestClock();
39
+ try {
40
+ let count = 0;
41
+ const id = setInterval(() => count++, 1000);
42
+
43
+ await clock.advance(3500);
44
+ assert.equal(count, 3);
45
+
46
+ clearInterval(id);
47
+ await clock.advance(10_000);
48
+ assert.equal(count, 3);
49
+ } finally {
50
+ clock.restore();
51
+ }
52
+ });
53
+
54
+ test("timers scheduled inside a firing callback are picked up by the same advance() call", async () => {
55
+ const clock = installTestClock();
56
+ try {
57
+ const seen: number[] = [];
58
+
59
+ setTimeout(() => {
60
+ seen.push(1);
61
+ setTimeout(() => seen.push(2), 100);
62
+ }, 100);
63
+
64
+ await clock.advance(500);
65
+ assert.deepEqual(seen, [1, 2]);
66
+ } finally {
67
+ clock.restore();
68
+ }
69
+ });
70
+
71
+ test("restore() puts back the real Date.now/setTimeout", () => {
72
+ const realNow = Date.now;
73
+ const realSetTimeout = setTimeout;
74
+
75
+ const clock = installTestClock();
76
+ clock.restore();
77
+
78
+ assert.equal(Date.now, realNow);
79
+ assert.equal(setTimeout, realSetTimeout);
80
+ });
package/src/clock.ts ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * a virtual clock: overrides `Date.now`, `setTimeout`/`clearTimeout`, and
3
+ * `setInterval`/`clearInterval` so TTL / retry-delay / debounce code under test
4
+ * can be advanced instantly instead of waited on for real. installs globally
5
+ * for the lifetime of the clock; always `.restore()` when done (or let
6
+ * {@link TestEnv.shutdown} do it for you).
7
+ */
8
+ export interface TestClock {
9
+ /** the clock's current virtual time, in ms since epoch. */
10
+ now(): number;
11
+ /** advance the clock by `ms`, firing every timer whose deadline falls inside the window (in deadline order). intervals re-arm and may fire multiple times. */
12
+ advance(ms: number): Promise<void>;
13
+ /** uninstall — restores the real `Date.now`/`setTimeout`/`setInterval`. */
14
+ restore(): void;
15
+ }
16
+
17
+ interface Timer {
18
+ id: number;
19
+ due: number;
20
+ interval: number | undefined; // undefined = one-shot (setTimeout)
21
+ fn: (...args: unknown[]) => void;
22
+ args: unknown[];
23
+ cancelled: boolean;
24
+ }
25
+
26
+ /**
27
+ * install a virtual clock, overriding the global timer functions. `startAt`
28
+ * defaults to the real current time so unrelated `Date.now()` reads (e.g. log
29
+ * timestamps) stay plausible.
30
+ */
31
+ export function installTestClock(startAt: number = Date.now()): TestClock {
32
+ const realDateNow = Date.now;
33
+ const realSetTimeout = globalThis.setTimeout;
34
+ const realClearTimeout = globalThis.clearTimeout;
35
+ const realSetInterval = globalThis.setInterval;
36
+ const realClearInterval = globalThis.clearInterval;
37
+
38
+ let now = startAt;
39
+ let nextId = 1;
40
+ const timers = new Map<number, Timer>();
41
+
42
+ const schedule = (
43
+ fn: (...args: unknown[]) => void,
44
+ delay: number,
45
+ interval: number | undefined,
46
+ args: unknown[],
47
+ ): number => {
48
+ const id = nextId++;
49
+ timers.set(id, {
50
+ id,
51
+ due: now + Math.max(0, delay || 0),
52
+ interval,
53
+ fn,
54
+ args,
55
+ cancelled: false,
56
+ });
57
+ return id;
58
+ };
59
+
60
+ Date.now = () => now;
61
+
62
+ globalThis.setTimeout = ((fn: (...args: unknown[]) => void, delay?: number, ...args: unknown[]) =>
63
+ schedule(fn, delay ?? 0, undefined, args)) as unknown as typeof setTimeout;
64
+
65
+ globalThis.clearTimeout = ((id?: number | ReturnType<typeof setTimeout>) => {
66
+ if (typeof id === "number") timers.delete(id);
67
+ }) as unknown as typeof clearTimeout;
68
+
69
+ globalThis.setInterval = ((
70
+ fn: (...args: unknown[]) => void,
71
+ delay?: number,
72
+ ...args: unknown[]
73
+ ) => schedule(fn, delay ?? 0, delay ?? 0, args)) as unknown as typeof setInterval;
74
+
75
+ globalThis.clearInterval = ((id?: number | ReturnType<typeof setInterval>) => {
76
+ if (typeof id === "number") timers.delete(id);
77
+ }) as unknown as typeof clearInterval;
78
+
79
+ async function advance(ms: number): Promise<void> {
80
+ const target = now + ms;
81
+
82
+ for (;;) {
83
+ let next: Timer | undefined;
84
+
85
+ for (const timer of timers.values()) {
86
+ if (timer.due > target) continue;
87
+ if (!next || timer.due < next.due || (timer.due === next.due && timer.id < next.id)) {
88
+ next = timer;
89
+ }
90
+ }
91
+
92
+ if (!next) break;
93
+
94
+ now = next.due;
95
+
96
+ if (next.interval === undefined) {
97
+ timers.delete(next.id);
98
+ } else {
99
+ next.due = now + Math.max(1, next.interval);
100
+ }
101
+
102
+ await next.fn(...next.args);
103
+ }
104
+
105
+ now = target;
106
+ }
107
+
108
+ function restore(): void {
109
+ Date.now = realDateNow;
110
+ globalThis.setTimeout = realSetTimeout;
111
+ globalThis.clearTimeout = realClearTimeout;
112
+ globalThis.setInterval = realSetInterval;
113
+ globalThis.clearInterval = realClearInterval;
114
+ timers.clear();
115
+ }
116
+
117
+ return { now: () => now, advance, restore };
118
+ }