mirror of
https://github.com/lukaszraczylo/go-telegram.git
synced 2026-06-11 23:19:31 +00:00
Initial release of go-telegram
A fully-generated, strongly-typed Go client for the Telegram Bot API. * 176 methods + 301 types generated from Bot API v10.0 * 1408 auto-generated tests (8 scenarios per method) * Typed unions throughout — no 'any' in the public surface * Pluggable HTTP transport and JSON codec (default goccy/go-json) * Built-in retry middleware honouring Telegram's retry_after * Generic dispatcher with filters and conversation handlers * Self-verifying codegen pipeline (regen → audit → emit → run tests) * 14 example bots covering common patterns
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/go-telegram/internal/spec"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loadIR
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestLoadIR_ValidFile(t *testing.T) {
|
||||
api := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "getMe", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(api)
|
||||
require.NoError(t, err)
|
||||
|
||||
tmp := filepath.Join(t.TempDir(), "api.json")
|
||||
require.NoError(t, os.WriteFile(tmp, data, 0o600))
|
||||
|
||||
loaded, err := loadIR(tmp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, loaded.Methods, 1)
|
||||
require.Equal(t, "getMe", loaded.Methods[0].Name)
|
||||
}
|
||||
|
||||
func TestLoadIR_MissingFile(t *testing.T) {
|
||||
_, err := loadIR("/nonexistent/path/api.json")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "open IR")
|
||||
}
|
||||
|
||||
func TestLoadIR_InvalidJSON(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "bad.json")
|
||||
require.NoError(t, os.WriteFile(tmp, []byte("not json"), 0o600))
|
||||
|
||||
_, err := loadIR(tmp)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "decode IR")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// auditBool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAuditBool_LongDocTruncated(t *testing.T) {
|
||||
longDoc := make([]byte, 200)
|
||||
for i := range longDoc {
|
||||
longDoc[i] = 'a'
|
||||
}
|
||||
api := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "myMethod", Doc: string(longDoc), Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
|
||||
},
|
||||
}
|
||||
problems := auditBool(api, &spec.Overrides{})
|
||||
require.Len(t, problems, 1)
|
||||
require.Contains(t, problems[0], "…")
|
||||
}
|
||||
|
||||
func TestAuditBool_TrueIsReturnedVariant(t *testing.T) {
|
||||
api := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "doThing", Doc: "true is returned on success.", Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
|
||||
},
|
||||
}
|
||||
require.Empty(t, auditBool(api, &spec.Overrides{}))
|
||||
}
|
||||
|
||||
func TestAuditBool_ReturnsBoolean(t *testing.T) {
|
||||
api := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "doThing", Doc: "Returns Boolean on success.", Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
|
||||
},
|
||||
}
|
||||
require.Empty(t, auditBool(api, &spec.Overrides{}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatTypeRef
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFormatTypeRef_AllBranches(t *testing.T) {
|
||||
cases := []struct {
|
||||
tr spec.TypeRef
|
||||
want string
|
||||
}{
|
||||
{spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}, "bool"},
|
||||
{spec.TypeRef{Kind: spec.KindNamed, Name: "User"}, "User"},
|
||||
{spec.TypeRef{Kind: spec.KindArray, ElemType: &spec.TypeRef{Kind: spec.KindNamed, Name: "Update"}}, "[]Update"},
|
||||
{spec.TypeRef{Kind: spec.KindArray}, "[]any"},
|
||||
{spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"int64", "string"}}, "(int64 | string)"},
|
||||
{spec.TypeRef{Kind: spec.Kind(99)}, "?"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := formatTypeRef(c.tr)
|
||||
require.Equal(t, c.want, got, "for kind=%v name=%v", c.tr.Kind, c.tr.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// auditDrift
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAuditDrift_InvalidRefReturnsError(t *testing.T) {
|
||||
cur := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "getMe", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
|
||||
},
|
||||
}
|
||||
_, err := auditDrift("internal/spec/api.json", "THIS_REF_DOES_NOT_EXIST", cur)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAuditDrift_SameRefNoDrift(t *testing.T) {
|
||||
irPath := "../../internal/spec/api.json"
|
||||
cur, err := loadIR(irPath)
|
||||
if err != nil {
|
||||
t.Skip("api.json not available, skipping drift test")
|
||||
}
|
||||
changes, err := auditDrift(irPath, "HEAD", cur)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, changes)
|
||||
}
|
||||
|
||||
func TestAuditDrift_InvalidJSONFromGit(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell script not supported on Windows")
|
||||
}
|
||||
tmp := t.TempDir()
|
||||
fakeGit := filepath.Join(tmp, "git")
|
||||
require.NoError(t, os.WriteFile(fakeGit, []byte("#!/bin/sh\necho 'not valid json'\n"), 0o600))
|
||||
require.NoError(t, os.Chmod(fakeGit, 0o755))
|
||||
|
||||
origPATH := os.Getenv("PATH")
|
||||
t.Cleanup(func() { _ = os.Setenv("PATH", origPATH) })
|
||||
_ = os.Setenv("PATH", tmp+string(os.PathListSeparator)+origPATH)
|
||||
|
||||
_, err := auditDrift("internal/spec/api.json", "HEAD", &spec.API{})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "decode")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// auditAny
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAuditAny_FlagsUnknownMethodReturn(t *testing.T) {
|
||||
api := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{
|
||||
Name: "weirdMethod",
|
||||
Returns: spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"A", "B", "C"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
out := auditAny(api)
|
||||
require.Len(t, out, 1)
|
||||
require.Contains(t, out[0], "any return: weirdMethod")
|
||||
}
|
||||
|
||||
func TestAuditAny_FlagsUnknownMethodParam(t *testing.T) {
|
||||
api := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{
|
||||
Name: "weirdMethod",
|
||||
Params: []spec.Field{
|
||||
{Name: "Thing", JSONName: "thing", Type: spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"X", "Y", "Z"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
out := auditAny(api)
|
||||
require.Len(t, out, 1)
|
||||
require.Contains(t, out[0], "any param: weirdMethod.Thing")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// diffSignatures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDiffSignatures_UnchangedNoDrift(t *testing.T) {
|
||||
prev := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "getMe", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
|
||||
},
|
||||
}
|
||||
cur := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "getMe", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
|
||||
},
|
||||
}
|
||||
require.Empty(t, diffSignatures(prev, cur))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// typeRefEqual
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestTypeRefEqual_ArrayNilElemDiffers(t *testing.T) {
|
||||
a := spec.TypeRef{Kind: spec.KindArray}
|
||||
b := spec.TypeRef{Kind: spec.KindArray, ElemType: &spec.TypeRef{Kind: spec.KindNamed, Name: "Update"}}
|
||||
require.False(t, typeRefEqual(a, b))
|
||||
require.False(t, typeRefEqual(b, a))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestHelperMain: subprocess helper for main() coverage.
|
||||
//
|
||||
// When AUDIT_HELPER_MAIN=1 is set, this function:
|
||||
// 1. Resets flag.CommandLine so main()'s flag.Parse() gets a clean slate.
|
||||
// 2. Sets os.Args to the args encoded in AUDIT_HELPER_ARGS.
|
||||
// 3. Calls main() which calls os.Exit — the test process terminates with
|
||||
// main's exit code, which the parent test captures.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHelperMain(t *testing.T) {
|
||||
if os.Getenv("AUDIT_HELPER_MAIN") != "1" {
|
||||
t.Skip("not running as subprocess helper")
|
||||
}
|
||||
// Reset flag.CommandLine so main()'s flag.Parse() gets a clean slate.
|
||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||
|
||||
// Decode args from JSON-encoded env var.
|
||||
encoded := os.Getenv("AUDIT_HELPER_ARGS")
|
||||
if encoded != "" {
|
||||
var args []string
|
||||
if err := json.Unmarshal([]byte(encoded), &args); err == nil {
|
||||
os.Args = append([]string{os.Args[0]}, args...)
|
||||
}
|
||||
} else {
|
||||
os.Args = os.Args[:1]
|
||||
}
|
||||
|
||||
main()
|
||||
}
|
||||
|
||||
// runMain runs main() via the test binary subprocess so that coverage counters
|
||||
// from main() are included in the profile. Args are JSON-encoded in an env var
|
||||
// to avoid conflicts with the test binary's own flag parsing.
|
||||
func runMain(t *testing.T, extraEnv []string, args ...string) (string, int) {
|
||||
t.Helper()
|
||||
argsJSON, _ := json.Marshal(args)
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestHelperMain", "-test.v=false")
|
||||
cmd.Env = append(os.Environ(), "AUDIT_HELPER_MAIN=1", "AUDIT_HELPER_ARGS="+string(argsJSON))
|
||||
cmd.Env = append(cmd.Env, extraEnv...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
code := 0
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
code = ee.ExitCode()
|
||||
}
|
||||
}
|
||||
return string(out), code
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main() integration tests — exercise main() code paths via subprocess
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMain_CleanExitsZero(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
ir := writeIR(t, tmp, &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "getMe", Doc: "Returns True on success.", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
|
||||
},
|
||||
})
|
||||
ov := writeOverrides(t, tmp)
|
||||
|
||||
out, code := runMain(t, nil, "-ir", ir, "-overrides", ov)
|
||||
require.Equal(t, exitClean, code, "expected exit 0 (clean)\nout: %s", out)
|
||||
require.Contains(t, out, "clean")
|
||||
}
|
||||
|
||||
func TestMain_FallbackExitsOne(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
ir := writeIR(t, tmp, &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "doSomething", Doc: "Does something.", Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
|
||||
},
|
||||
})
|
||||
ov := writeOverrides(t, tmp)
|
||||
|
||||
out, code := runMain(t, nil, "-ir", ir, "-overrides", ov)
|
||||
require.Equal(t, exitFallback, code, "expected exit 1 (fallback)\nout: %s", out)
|
||||
require.Contains(t, out, "bool fallback")
|
||||
}
|
||||
|
||||
func TestMain_InvalidIRExitsThree(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
bad := filepath.Join(tmp, "bad.json")
|
||||
require.NoError(t, os.WriteFile(bad, []byte("not json"), 0o600))
|
||||
ov := writeOverrides(t, tmp)
|
||||
|
||||
out, code := runMain(t, nil, "-ir", bad, "-overrides", ov)
|
||||
require.Equal(t, exitInvalid, code, "expected exit 3 (invalid IR)\nout: %s", out)
|
||||
}
|
||||
|
||||
func TestMain_InvalidOverridesExitsThree(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
ir := writeIR(t, tmp, &spec.API{})
|
||||
bad := filepath.Join(tmp, "bad_ov.json")
|
||||
require.NoError(t, os.WriteFile(bad, []byte("not json"), 0o600))
|
||||
|
||||
out, code := runMain(t, nil, "-ir", ir, "-overrides", bad)
|
||||
require.Equal(t, exitInvalid, code, "expected exit 3 (invalid overrides)\nout: %s", out)
|
||||
}
|
||||
|
||||
func TestMain_DriftDetectedExitsTwo(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell script not supported on Windows")
|
||||
}
|
||||
tmp := t.TempDir()
|
||||
|
||||
prevAPI := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "getMe", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
|
||||
},
|
||||
}
|
||||
curAPI := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "getMe", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "Message"}},
|
||||
},
|
||||
}
|
||||
|
||||
curIR := writeIR(t, tmp, curAPI)
|
||||
ov := writeOverrides(t, tmp)
|
||||
|
||||
prevData, err := json.Marshal(prevAPI)
|
||||
require.NoError(t, err)
|
||||
prevFile := filepath.Join(tmp, "prev.json")
|
||||
require.NoError(t, os.WriteFile(prevFile, prevData, 0o600))
|
||||
|
||||
fakeGit := filepath.Join(tmp, "git")
|
||||
script := fmt.Sprintf("#!/bin/sh\ncat %s\n", prevFile)
|
||||
require.NoError(t, os.WriteFile(fakeGit, []byte(script), 0o600))
|
||||
require.NoError(t, os.Chmod(fakeGit, 0o755))
|
||||
|
||||
newPATH := tmp + string(os.PathListSeparator) + os.Getenv("PATH")
|
||||
|
||||
out, code := runMain(t,
|
||||
[]string{"PATH=" + newPATH},
|
||||
"-ir", curIR, "-overrides", ov, "-drift", "-against", "HEAD~1",
|
||||
)
|
||||
require.Equal(t, exitDrift, code, "expected exit 2 (drift)\nout: %s", out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func writeIR(t *testing.T, dir string, api *spec.API) string {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(api)
|
||||
require.NoError(t, err)
|
||||
p := filepath.Join(dir, "api.json")
|
||||
require.NoError(t, os.WriteFile(p, data, 0o600))
|
||||
return p
|
||||
}
|
||||
|
||||
func writeOverrides(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, "overrides.json")
|
||||
require.NoError(t, os.WriteFile(p, []byte("{}"), 0o600))
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// Command audit reports IR-level codegen fallbacks and signature drift.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// audit -ir <path> (default internal/spec/api.json)
|
||||
// audit -overrides <path> (default internal/spec/overrides.json)
|
||||
// audit -drift (compare against -against ref's IR; off by default)
|
||||
// audit -against <ref> (git ref to diff drift against; default HEAD~1)
|
||||
//
|
||||
// Exit codes:
|
||||
//
|
||||
// 0 — clean
|
||||
// 1 — unaccounted bool fallbacks or any-typed fields
|
||||
// 2 — drift detected (signature changed)
|
||||
// 3 — invalid IR or overrides
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/goccy/go-json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/lukaszraczylo/go-telegram/internal/spec"
|
||||
)
|
||||
|
||||
const (
|
||||
exitClean = 0
|
||||
exitFallback = 1
|
||||
exitDrift = 2
|
||||
exitInvalid = 3
|
||||
)
|
||||
|
||||
func main() {
|
||||
irPath := flag.String("ir", "internal/spec/api.json", "path to IR JSON")
|
||||
ovPath := flag.String("overrides", "internal/spec/overrides.json", "path to overrides JSON")
|
||||
checkDrift := flag.Bool("drift", false, "compare against -against ref's IR for signature changes")
|
||||
againstRef := flag.String("against", "HEAD~1", "git ref to diff drift against (e.g. origin/main, HEAD~1)")
|
||||
flag.Parse()
|
||||
|
||||
api, err := loadIR(*irPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "audit:", err)
|
||||
os.Exit(exitInvalid)
|
||||
}
|
||||
overrides, err := spec.LoadOverrides(*ovPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "audit:", err)
|
||||
os.Exit(exitInvalid)
|
||||
}
|
||||
|
||||
var problems []string
|
||||
|
||||
problems = append(problems, auditBool(api, overrides)...)
|
||||
problems = append(problems, auditAny(api)...)
|
||||
|
||||
driftFound := false
|
||||
if *checkDrift {
|
||||
if d, err := auditDrift(*irPath, *againstRef, api); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "audit: drift check skipped:", err)
|
||||
} else if len(d) > 0 {
|
||||
fmt.Println("Drift detected (signatures changed since HEAD):")
|
||||
for _, p := range d {
|
||||
fmt.Println(" ", p)
|
||||
}
|
||||
driftFound = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(problems) == 0 && !driftFound {
|
||||
fmt.Println("audit: clean")
|
||||
os.Exit(exitClean)
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
fmt.Println("Codegen fallbacks requiring action:")
|
||||
for _, p := range problems {
|
||||
fmt.Println(" ", p)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("To resolve: extend cmd/scrape/method.go regex patterns OR")
|
||||
fmt.Println("add an entry to internal/spec/overrides.json.")
|
||||
os.Exit(exitFallback)
|
||||
}
|
||||
|
||||
// drift only, no fallbacks
|
||||
os.Exit(exitDrift)
|
||||
}
|
||||
|
||||
func loadIR(path string) (*spec.API, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open IR: %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
var api spec.API
|
||||
if err := json.NewDecoder(f).Decode(&api); err != nil {
|
||||
return nil, fmt.Errorf("decode IR: %w", err)
|
||||
}
|
||||
return &api, nil
|
||||
}
|
||||
|
||||
// auditBool returns problems for methods returning bool whose docs don't
|
||||
// actually say "Returns True" / etc. and which aren't in the approved list.
|
||||
func auditBool(api *spec.API, ov *spec.Overrides) []string {
|
||||
var out []string
|
||||
for _, m := range api.Methods {
|
||||
if m.Returns.Kind != spec.KindPrimitive || m.Returns.Name != "bool" {
|
||||
continue
|
||||
}
|
||||
if ov.IsBoolApproved(m.Name) {
|
||||
continue
|
||||
}
|
||||
if looksGenuinelyBool(m.Doc) {
|
||||
continue
|
||||
}
|
||||
snippet := m.Doc
|
||||
if len(snippet) > 120 {
|
||||
snippet = snippet[:120] + "…"
|
||||
}
|
||||
out = append(out, fmt.Sprintf("bool fallback: %s — doc: %q", m.Name, snippet))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func looksGenuinelyBool(doc string) bool {
|
||||
for _, p := range []string{
|
||||
"Returns True", "Returns true",
|
||||
"True is returned", "true is returned",
|
||||
"Returns Boolean", "Returns Bool",
|
||||
} {
|
||||
if strings.Contains(doc, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// auditAny scans the IR for any KindOneOf TypeRef that would render as
|
||||
// `any` in generated code (not matched by ChatID/InputFile-or-string/known
|
||||
// union heuristics). Reports each occurrence with location.
|
||||
func auditAny(api *spec.API) []string {
|
||||
var out []string
|
||||
isKnownUnion := func(variants []string) bool {
|
||||
if hasVariants(variants, "int64", "string") {
|
||||
return true // ChatID
|
||||
}
|
||||
if hasVariants(variants, "InputFile", "string") {
|
||||
return true // *InputFile
|
||||
}
|
||||
// ReplyMarkup union: all four keyboard types — emitter renders as `any` intentionally
|
||||
if hasVariants(variants, "InlineKeyboardMarkup", "ReplyKeyboardMarkup", "ReplyKeyboardRemove", "ForceReply") {
|
||||
return true
|
||||
}
|
||||
for _, t := range api.Types {
|
||||
if len(t.OneOf) > 0 && sameSet(variants, t.OneOf) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
isAny := func(tr spec.TypeRef) bool {
|
||||
return tr.Kind == spec.KindOneOf && !isKnownUnion(tr.Variants)
|
||||
}
|
||||
for _, t := range api.Types {
|
||||
for _, f := range t.Fields {
|
||||
if isAny(f.Type) {
|
||||
out = append(out, fmt.Sprintf("any field: %s.%s (variants=%v)", t.Name, f.Name, f.Type.Variants))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range api.Methods {
|
||||
if isAny(m.Returns) {
|
||||
out = append(out, fmt.Sprintf("any return: %s (variants=%v)", m.Name, m.Returns.Variants))
|
||||
}
|
||||
for _, p := range m.Params {
|
||||
if isAny(p.Type) {
|
||||
out = append(out, fmt.Sprintf("any param: %s.%s (variants=%v)", m.Name, p.Name, p.Type.Variants))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasVariants(got []string, want ...string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
seen := map[string]int{}
|
||||
for _, g := range got {
|
||||
seen[g]++
|
||||
}
|
||||
for _, w := range want {
|
||||
seen[w]--
|
||||
}
|
||||
for _, v := range seen {
|
||||
if v != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sameSet(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
return hasVariants(a, b...)
|
||||
}
|
||||
|
||||
// auditDrift compares method/return signatures between the given git ref's
|
||||
// version of irPath and the in-memory current IR. Returns a list of
|
||||
// human-readable change descriptions.
|
||||
func auditDrift(irPath, againstRef string, current *spec.API) ([]string, error) {
|
||||
cmd := exec.Command("git", "show", againstRef+":"+irPath) // #nosec G204 - operator tool, ref controlled by caller
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("git show %s: %w", againstRef, err)
|
||||
}
|
||||
var prev spec.API
|
||||
if err := json.Unmarshal(out, &prev); err != nil {
|
||||
return nil, fmt.Errorf("decode %s IR: %w", againstRef, err)
|
||||
}
|
||||
return diffSignatures(&prev, current), nil
|
||||
}
|
||||
|
||||
func diffSignatures(prev, cur *spec.API) []string {
|
||||
var changes []string
|
||||
|
||||
pmeth := indexByName(prev.Methods, func(m spec.MethodDecl) string { return m.Name })
|
||||
cmeth := indexByName(cur.Methods, func(m spec.MethodDecl) string { return m.Name })
|
||||
|
||||
for name, p := range pmeth {
|
||||
c, ok := cmeth[name]
|
||||
if !ok {
|
||||
changes = append(changes, fmt.Sprintf("removed method: %s", name))
|
||||
continue
|
||||
}
|
||||
if !typeRefEqual(p.Returns, c.Returns) {
|
||||
changes = append(changes, fmt.Sprintf(
|
||||
"method %s return changed: %s → %s",
|
||||
name, formatTypeRef(p.Returns), formatTypeRef(c.Returns)))
|
||||
}
|
||||
}
|
||||
for name := range cmeth {
|
||||
if _, ok := pmeth[name]; !ok {
|
||||
changes = append(changes, fmt.Sprintf("added method: %s", name))
|
||||
}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func indexByName[T any](xs []T, f func(T) string) map[string]T {
|
||||
out := map[string]T{}
|
||||
for _, x := range xs {
|
||||
out[f(x)] = x
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func typeRefEqual(a, b spec.TypeRef) bool {
|
||||
if a.Kind != b.Kind || a.Name != b.Name {
|
||||
return false
|
||||
}
|
||||
if (a.ElemType == nil) != (b.ElemType == nil) {
|
||||
return false
|
||||
}
|
||||
if a.ElemType != nil && !typeRefEqual(*a.ElemType, *b.ElemType) {
|
||||
return false
|
||||
}
|
||||
return sameSet(a.Variants, b.Variants)
|
||||
}
|
||||
|
||||
func formatTypeRef(t spec.TypeRef) string {
|
||||
switch t.Kind {
|
||||
case spec.KindPrimitive:
|
||||
return t.Name
|
||||
case spec.KindNamed:
|
||||
return t.Name
|
||||
case spec.KindArray:
|
||||
if t.ElemType != nil {
|
||||
return "[]" + formatTypeRef(*t.ElemType)
|
||||
}
|
||||
return "[]any"
|
||||
case spec.KindOneOf:
|
||||
return "(" + strings.Join(t.Variants, " | ") + ")"
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/go-telegram/internal/spec"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---- auditBool -----------------------------------------------------------
|
||||
|
||||
func TestAuditBool_FlagsUnapprovedBoolMethod(t *testing.T) {
|
||||
api := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "getMe", Doc: "A simple method.", Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
|
||||
},
|
||||
}
|
||||
ov := &spec.Overrides{}
|
||||
problems := auditBool(api, ov)
|
||||
require.Len(t, problems, 1)
|
||||
require.Contains(t, problems[0], "bool fallback: getMe")
|
||||
}
|
||||
|
||||
func TestAuditBool_SkipsApprovedBoolMethod(t *testing.T) {
|
||||
api := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "setWebhook", Doc: "Use this to set webhook.", Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
|
||||
},
|
||||
}
|
||||
ov := &spec.Overrides{ApprovedBoolMethods: []string{"setWebhook"}}
|
||||
problems := auditBool(api, ov)
|
||||
require.Empty(t, problems)
|
||||
}
|
||||
|
||||
func TestAuditBool_SkipsMethodWithReturnsTrueDoc(t *testing.T) {
|
||||
api := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "doThing", Doc: "Returns True on success.", Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
|
||||
},
|
||||
}
|
||||
ov := &spec.Overrides{}
|
||||
problems := auditBool(api, ov)
|
||||
require.Empty(t, problems)
|
||||
}
|
||||
|
||||
func TestAuditBool_SkipsNonBoolMethods(t *testing.T) {
|
||||
api := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "getMe", Doc: "Gets user.", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
|
||||
},
|
||||
}
|
||||
ov := &spec.Overrides{}
|
||||
require.Empty(t, auditBool(api, ov))
|
||||
}
|
||||
|
||||
// ---- auditAny ------------------------------------------------------------
|
||||
|
||||
func TestAuditAny_FlagsUnrecognisedOneOf(t *testing.T) {
|
||||
api := &spec.API{
|
||||
Types: []spec.TypeDecl{
|
||||
{
|
||||
Name: "Foo",
|
||||
Fields: []spec.Field{
|
||||
{Name: "Bar", JSONName: "bar", Type: spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"A", "B", "C"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
out := auditAny(api)
|
||||
require.Len(t, out, 1)
|
||||
require.Contains(t, out[0], "any field: Foo.Bar")
|
||||
}
|
||||
|
||||
func TestAuditAny_SkipsChatIDShape(t *testing.T) {
|
||||
api := &spec.API{
|
||||
Types: []spec.TypeDecl{
|
||||
{
|
||||
Name: "SendMessage",
|
||||
Fields: []spec.Field{
|
||||
{Name: "ChatID", JSONName: "chat_id", Type: spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"int64", "string"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Empty(t, auditAny(api))
|
||||
}
|
||||
|
||||
func TestAuditAny_SkipsKnownUnion(t *testing.T) {
|
||||
api := &spec.API{
|
||||
Types: []spec.TypeDecl{
|
||||
{Name: "InputMedia", OneOf: []string{"InputMediaPhoto", "InputMediaVideo"}},
|
||||
{
|
||||
Name: "SomeMethod",
|
||||
Fields: []spec.Field{
|
||||
{Name: "Media", JSONName: "media", Type: spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"InputMediaPhoto", "InputMediaVideo"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Empty(t, auditAny(api))
|
||||
}
|
||||
|
||||
func TestAuditAny_SkipsReplyMarkupShape(t *testing.T) {
|
||||
api := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{
|
||||
Name: "sendMessage",
|
||||
Params: []spec.Field{
|
||||
{Name: "ReplyMarkup", JSONName: "reply_markup", Type: spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"InlineKeyboardMarkup", "ReplyKeyboardMarkup", "ReplyKeyboardRemove", "ForceReply"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Empty(t, auditAny(api))
|
||||
}
|
||||
|
||||
func TestAuditAny_SkipsInputFileShape(t *testing.T) {
|
||||
api := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{
|
||||
Name: "sendPhoto",
|
||||
Params: []spec.Field{
|
||||
{Name: "Photo", JSONName: "photo", Type: spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"InputFile", "string"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Empty(t, auditAny(api))
|
||||
}
|
||||
|
||||
// ---- diffSignatures ------------------------------------------------------
|
||||
|
||||
func TestDiffSignatures_AddedMethod(t *testing.T) {
|
||||
prev := &spec.API{}
|
||||
cur := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "newMethod", Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
|
||||
},
|
||||
}
|
||||
changes := diffSignatures(prev, cur)
|
||||
require.Len(t, changes, 1)
|
||||
require.Contains(t, changes[0], "added method: newMethod")
|
||||
}
|
||||
|
||||
func TestDiffSignatures_RemovedMethod(t *testing.T) {
|
||||
prev := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "oldMethod", Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
|
||||
},
|
||||
}
|
||||
cur := &spec.API{}
|
||||
changes := diffSignatures(prev, cur)
|
||||
require.Len(t, changes, 1)
|
||||
require.Contains(t, changes[0], "removed method: oldMethod")
|
||||
}
|
||||
|
||||
func TestDiffSignatures_ChangedReturn(t *testing.T) {
|
||||
prev := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "getMe", Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
|
||||
},
|
||||
}
|
||||
cur := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "getMe", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
|
||||
},
|
||||
}
|
||||
changes := diffSignatures(prev, cur)
|
||||
require.Len(t, changes, 1)
|
||||
require.Contains(t, changes[0], "getMe")
|
||||
require.Contains(t, changes[0], "bool")
|
||||
require.Contains(t, changes[0], "User")
|
||||
}
|
||||
|
||||
func TestDiffSignatures_Clean(t *testing.T) {
|
||||
api := &spec.API{
|
||||
Methods: []spec.MethodDecl{
|
||||
{Name: "getMe", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
|
||||
},
|
||||
}
|
||||
require.Empty(t, diffSignatures(api, api))
|
||||
}
|
||||
|
||||
// ---- typeRefEqual --------------------------------------------------------
|
||||
|
||||
func TestTypeRefEqual_Primitive(t *testing.T) {
|
||||
a := spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}
|
||||
require.True(t, typeRefEqual(a, a))
|
||||
b := spec.TypeRef{Kind: spec.KindPrimitive, Name: "string"}
|
||||
require.False(t, typeRefEqual(a, b))
|
||||
}
|
||||
|
||||
func TestTypeRefEqual_Array(t *testing.T) {
|
||||
elem := &spec.TypeRef{Kind: spec.KindNamed, Name: "Update"}
|
||||
a := spec.TypeRef{Kind: spec.KindArray, ElemType: elem}
|
||||
b := spec.TypeRef{Kind: spec.KindArray, ElemType: &spec.TypeRef{Kind: spec.KindNamed, Name: "Update"}}
|
||||
require.True(t, typeRefEqual(a, b))
|
||||
|
||||
c := spec.TypeRef{Kind: spec.KindArray, ElemType: &spec.TypeRef{Kind: spec.KindNamed, Name: "Message"}}
|
||||
require.False(t, typeRefEqual(a, c))
|
||||
}
|
||||
|
||||
func TestTypeRefEqual_OneOf(t *testing.T) {
|
||||
a := spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"int64", "string"}}
|
||||
b := spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"string", "int64"}}
|
||||
require.True(t, typeRefEqual(a, b))
|
||||
|
||||
c := spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"int64"}}
|
||||
require.False(t, typeRefEqual(a, c))
|
||||
}
|
||||
|
||||
func TestTypeRefEqual_NilVsNonNilElem(t *testing.T) {
|
||||
a := spec.TypeRef{Kind: spec.KindArray}
|
||||
b := spec.TypeRef{Kind: spec.KindArray, ElemType: &spec.TypeRef{Kind: spec.KindNamed, Name: "Update"}}
|
||||
require.False(t, typeRefEqual(a, b))
|
||||
}
|
||||
Reference in New Issue
Block a user