test(telegram): add unit tests for markdown, config, api

This commit is contained in:
opencode-agent 2026-07-31 17:33:54 +00:00
parent bc77b1d86f
commit 7299846e5d
4 changed files with 243 additions and 0 deletions

View file

@ -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<string, string>)["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()
})
})

View file

@ -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" })
})
})

View file

@ -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) })

View file

@ -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("\\\\*")
})
})