From 7299846e5d7b72b129966c50e9dd2aa8131a43af Mon Sep 17 00:00:00 2001 From: opencode-agent Date: Fri, 31 Jul 2026 17:33:54 +0000 Subject: [PATCH] test(telegram): add unit tests for markdown, config, api --- .opencode/telegram/tests/api.test.ts | 130 ++++++++++++++++++++++ .opencode/telegram/tests/config.test.ts | 54 +++++++++ .opencode/telegram/tests/e2e.manual.ts | 18 +++ .opencode/telegram/tests/markdown.test.ts | 41 +++++++ 4 files changed, 243 insertions(+) create mode 100644 .opencode/telegram/tests/api.test.ts create mode 100644 .opencode/telegram/tests/config.test.ts create mode 100644 .opencode/telegram/tests/e2e.manual.ts create mode 100644 .opencode/telegram/tests/markdown.test.ts diff --git a/.opencode/telegram/tests/api.test.ts b/.opencode/telegram/tests/api.test.ts new file mode 100644 index 0000000..d454400 --- /dev/null +++ b/.opencode/telegram/tests/api.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach, afterEach, vi, beforeAll, afterAll } from "vitest" +import { writeFileSync, unlinkSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { sendMessage, sendDocument, sendPhoto } from "../src/api.ts" + +const TOKEN = "TESTOKEN" +const CHAT = "123" + +const TMP_DOC = join(tmpdir(), "tg-test-doc.txt") +const TMP_PHOTO = join(tmpdir(), "tg-test-photo.png") + +function okResponse(result: unknown) { + const payload = { ok: true as const, result } + return { ...payload, json: async () => payload } +} + +beforeAll(() => { + writeFileSync(TMP_DOC, "hello document") + writeFileSync(TMP_PHOTO, "fake-png-bytes") +}) + +afterAll(() => { + unlinkSync(TMP_DOC) + unlinkSync(TMP_PHOTO) +}) + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("sendMessage", () => { + it("POSTs to sendMessage with chat_id, text; parse_mode omitted when undefined", async () => { + vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 42, chat: { id: 999 } }) as never) + await sendMessage(TOKEN, CHAT, "hi") + const [url, opts] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/sendMessage`) + expect(opts.method).toBe("POST") + expect((opts.headers as Record)["Content-Type"]).toBe("application/json") + const body = JSON.parse(opts.body as string) + expect(body).toEqual({ chat_id: CHAT, text: "hi" }) + }) + + it("POSTs to sendMessage with parse_mode=MarkdownV2 when explicitly passed", async () => { + vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 1, chat: { id: 1 } }) as never) + await sendMessage(TOKEN, CHAT, "hi", "MarkdownV2") + const [, opts] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + const body = JSON.parse(opts.body as string) + expect(body).toEqual({ chat_id: CHAT, text: "hi", parse_mode: "MarkdownV2" }) + }) + + it("parseMode='plain' omits parse_mode from body", async () => { + vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 1, chat: { id: 1 } }) as never) + await sendMessage(TOKEN, CHAT, "hi", "plain") + const [, opts] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + const body = JSON.parse(opts.body as string) + expect(body).toEqual({ chat_id: CHAT, text: "hi" }) + expect(body).not.toHaveProperty("parse_mode") + }) + + it("parseMode='HTML' sets parse_mode=HTML", async () => { + vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 1, chat: { id: 1 } }) as never) + await sendMessage(TOKEN, CHAT, "hi", "HTML") + const [, opts] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + const body = JSON.parse(opts.body as string) + expect(body.parse_mode).toBe("HTML") + }) + + it("returns {ok, message_id, chat_id} on success", async () => { + vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 42, chat: { id: 999 } }) as never) + const r = await sendMessage(TOKEN, CHAT, "hi") + expect(r).toEqual({ ok: true, message_id: 42, chat_id: "999" }) + }) + + it("throws on Telegram API error (ok:false)", async () => { + const errPayload = { ok: false, description: "Unauthorized" } + vi.mocked(fetch).mockResolvedValue({ ...errPayload, json: async () => errPayload } as never) + await expect(sendMessage(TOKEN, CHAT, "hi")).rejects.toThrow(/Unauthorized/) + }) + + it("throws on network error (fetch rejects)", async () => { + vi.mocked(fetch).mockRejectedValue(new Error("network")) + await expect(sendMessage(TOKEN, CHAT, "hi")).rejects.toThrow(/network/) + }) +}) + +describe("sendDocument", () => { + it("POSTs to sendDocument with FormData (chat_id, document, caption)", async () => { + vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 7, chat: { id: CHAT } }) as never) + const appendSpy = vi.spyOn(FormData.prototype, "append") + await sendDocument(TOKEN, CHAT, TMP_DOC, "cap", "MarkdownV2") + const [url, opts] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/sendDocument`) + expect(opts.method).toBe("POST") + const form = opts.body as FormData + expect(form.get("chat_id")).toBe(CHAT) + expect(form.get("caption")).toBe("cap") + expect(form.get("parse_mode")).toBe("MarkdownV2") + const doc = form.get("document") + expect(doc).toBeInstanceOf(Blob) + expect((doc as File).name).toBe("tg-test-doc.txt") + expect(appendSpy).toHaveBeenCalledWith("chat_id", CHAT) + expect(appendSpy).toHaveBeenCalledWith("document", expect.any(Blob), "tg-test-doc.txt") + appendSpy.mockRestore() + }) +}) + +describe("sendPhoto", () => { + it("POSTs to sendPhoto with FormData (chat_id, photo field)", async () => { + vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 9, chat: { id: CHAT } }) as never) + const appendSpy = vi.spyOn(FormData.prototype, "append") + await sendPhoto(TOKEN, CHAT, TMP_PHOTO, "cover", "HTML") + const [url, opts] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/sendPhoto`) + expect(opts.method).toBe("POST") + const form = opts.body as FormData + expect(form.get("chat_id")).toBe(CHAT) + expect(form.get("caption")).toBe("cover") + expect(form.get("parse_mode")).toBe("HTML") + const photo = form.get("photo") + expect(photo).toBeInstanceOf(Blob) + expect((photo as File).name).toBe("tg-test-photo.png") + expect(appendSpy).toHaveBeenCalledWith("photo", expect.any(Blob), "tg-test-photo.png") + appendSpy.mockRestore() + }) +}) \ No newline at end of file diff --git a/.opencode/telegram/tests/config.test.ts b/.opencode/telegram/tests/config.test.ts new file mode 100644 index 0000000..a647d0b --- /dev/null +++ b/.opencode/telegram/tests/config.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { loadConfig } from "../src/config.ts" + +describe("loadConfig", () => { + let savedToken: string | undefined + let savedChatId: string | undefined + + beforeEach(() => { + savedToken = process.env.TELEGRAM_BOT_TOKEN + savedChatId = process.env.TELEGRAM_CHAT_ID + delete process.env.TELEGRAM_BOT_TOKEN + delete process.env.TELEGRAM_CHAT_ID + }) + + afterEach(() => { + if (savedToken === undefined) { + delete process.env.TELEGRAM_BOT_TOKEN + } else { + process.env.TELEGRAM_BOT_TOKEN = savedToken + } + if (savedChatId === undefined) { + delete process.env.TELEGRAM_CHAT_ID + } else { + process.env.TELEGRAM_CHAT_ID = savedChatId + } + }) + + it("returns {token, chatId} when both env vars present", () => { + process.env.TELEGRAM_BOT_TOKEN = "tok" + process.env.TELEGRAM_CHAT_ID = "cid" + expect(loadConfig()).toEqual({ token: "tok", chatId: "cid" }) + }) + + it("throws when token missing", () => { + process.env.TELEGRAM_CHAT_ID = "cid" + expect(() => loadConfig()).toThrow(/TELEGRAM_BOT_TOKEN/) + }) + + it("throws when chatId missing without override", () => { + process.env.TELEGRAM_BOT_TOKEN = "tok" + expect(() => loadConfig()).toThrow(/chat_id/) + }) + + it("override chatId takes priority over env", () => { + process.env.TELEGRAM_BOT_TOKEN = "tok" + process.env.TELEGRAM_CHAT_ID = "env-id" + expect(loadConfig({ chatId: "argv-id" })).toEqual({ token: "tok", chatId: "argv-id" }) + }) + + it("override chatId works even when env chatId missing", () => { + process.env.TELEGRAM_BOT_TOKEN = "tok" + expect(loadConfig({ chatId: "argv-id" })).toEqual({ token: "tok", chatId: "argv-id" }) + }) +}) \ No newline at end of file diff --git a/.opencode/telegram/tests/e2e.manual.ts b/.opencode/telegram/tests/e2e.manual.ts new file mode 100644 index 0000000..3f9dbd1 --- /dev/null +++ b/.opencode/telegram/tests/e2e.manual.ts @@ -0,0 +1,18 @@ +// Manual E2E test — run with: +// node --experimental-strip-types tests/e2e.manual.ts +// Requires real TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID env vars. +import { sendMessage, sendDocument, sendPhoto } from "../src/api.ts" +import { loadConfig } from "../src/config.ts" + +async function main() { + const { token, chatId } = loadConfig() + console.log("[e2e] sending text to", chatId) + const r1 = await sendMessage(token, chatId, "E2E test: *bold* _italic_ `code`", "MarkdownV2") + console.log("[e2e] text sent:", r1) + // document + photo — раскомментируй и подставь пути к реальным файлам: + // const r2 = await sendDocument(token, chatId, "/path/to/file.md", "caption") + // console.log("[e2e] document sent:", r2) + // const r3 = await sendPhoto(token, chatId, "/path/to/cover.png", "cover") + // console.log("[e2e] photo sent:", r3) +} +main().catch((e) => { console.error("[e2e] failed:", e); process.exit(1) }) \ No newline at end of file diff --git a/.opencode/telegram/tests/markdown.test.ts b/.opencode/telegram/tests/markdown.test.ts new file mode 100644 index 0000000..f9c62b7 --- /dev/null +++ b/.opencode/telegram/tests/markdown.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest" +import { escapeMarkdownV2, MARKDOWN_V2_SPECIAL_CHARS } from "../src/markdown.ts" + +describe("escapeMarkdownV2", () => { + it("empty string → empty", () => { + expect(escapeMarkdownV2("")).toBe("") + }) + + it("string without special chars → unchanged", () => { + expect(escapeMarkdownV2("hello world 123 abc")).toBe("hello world 123 abc") + }) + + it("cyrillic → unchanged", () => { + expect(escapeMarkdownV2("Привет мир")).toBe("Привет мир") + }) + + it.each([...MARKDOWN_V2_SPECIAL_CHARS])("escapes single special char %j", (ch) => { + expect(escapeMarkdownV2(ch)).toBe("\\" + ch) + }) + + it("mix cyrillic + special chars: cyrillic untouched, special escaped", () => { + // comma and space are NOT in the special set — stay as-is + expect(escapeMarkdownV2("Привет, *мир*!")).toBe("Привет, \\*мир\\*\\!") + }) + + it("all special chars at once", () => { + const all = [...MARKDOWN_V2_SPECIAL_CHARS].join("") + const expected = [...MARKDOWN_V2_SPECIAL_CHARS].map((c) => "\\" + c).join("") + expect(escapeMarkdownV2(all)).toBe(expected) + }) + + it("backtick is escaped", () => { + expect(escapeMarkdownV2("`code`")).toBe("\\`code\\`") + }) + + it("backslash is NOT in MARKDOWN_V2_SPECIAL_CHARS and passes through", () => { + expect(MARKDOWN_V2_SPECIAL_CHARS as readonly string[]).not.toContain("\\") + // input `\*` (2 chars): backslash stays, asterisk escaped → `\\*` (3 chars) + expect(escapeMarkdownV2("\\*")).toBe("\\\\*") + }) +}) \ No newline at end of file