test(telegram): add unit tests for markdown, config, api
This commit is contained in:
parent
bc77b1d86f
commit
7299846e5d
4 changed files with 243 additions and 0 deletions
130
.opencode/telegram/tests/api.test.ts
Normal file
130
.opencode/telegram/tests/api.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
54
.opencode/telegram/tests/config.test.ts
Normal file
54
.opencode/telegram/tests/config.test.ts
Normal 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" })
|
||||
})
|
||||
})
|
||||
18
.opencode/telegram/tests/e2e.manual.ts
Normal file
18
.opencode/telegram/tests/e2e.manual.ts
Normal 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) })
|
||||
41
.opencode/telegram/tests/markdown.test.ts
Normal file
41
.opencode/telegram/tests/markdown.test.ts
Normal 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("\\\\*")
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue