Files
teabot/internal/docker/docker_test.go
T
unkinben 1b4448afb4
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add teabot daemon implementation
teabot watches Gitea repos and dispatches one-shot Claude Code sessions in
Docker containers to work issues and review PRs, acting as configurable bot
personalities.

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-26 23:36:21 +10:00

162 lines
4.9 KiB
Go

package docker
import (
"context"
"strings"
"testing"
"time"
)
// argsString joins docker args for easy substring assertions.
func argsString(a []string) string { return strings.Join(a, " ") }
// hasFlagValue reports whether args contains flag immediately followed by value.
func hasFlagValue(args []string, flag, value string) bool {
for i := 0; i+1 < len(args); i++ {
if args[i] == flag && args[i+1] == value {
return true
}
}
return false
}
func baseJob() Job {
return Job{
Label: "unkin/teabot#issue-1",
Image: "git.unkin.net/unkin/agent-dev:latest",
ContainerHome: "/home/agent",
Prompt: "do the thing",
CloneURL: "https://git.unkin.net/unkin/teabot.git",
GitHost: "git.unkin.net",
GitName: "Impl Bot",
GitEmail: "impl@unkin.net",
GitUser: "implbot",
Token: "secret-token",
TeaConfigPath: "/home/ben/.config/teabot/tea-impl.yml",
ClaudeConfigDir: "/home/ben/.claude",
}
}
func TestBuildArgsCoreShape(t *testing.T) {
r := &DockerRunner{DockerPath: "docker", SELinuxLabel: "z"}
args := r.buildArgs(baseJob(), "/tmp/job123")
s := argsString(args)
if args[0] != "run" {
t.Errorf("first arg = %q, want run", args[0])
}
if !hasFlagValue(args, "--entrypoint", "/bin/bash") {
t.Error("missing --entrypoint /bin/bash")
}
if !strings.Contains(s, "--rm") {
t.Error("missing --rm")
}
// Image and job script must be the trailing args.
if args[len(args)-2] != "git.unkin.net/unkin/agent-dev:latest" || args[len(args)-1] != "/teabot/job.sh" {
t.Errorf("trailing args = %v", args[len(args)-2:])
}
}
func TestBuildArgsMountsWithSELinuxLabel(t *testing.T) {
r := &DockerRunner{DockerPath: "docker", SELinuxLabel: "z"}
args := r.buildArgs(baseJob(), "/tmp/job123")
s := argsString(args)
// Prompt + job script mounted read-only with the SELinux relabel.
if !strings.Contains(s, "/tmp/job123/prompt.txt:/teabot/prompt.txt:ro,z") {
t.Error("prompt mount missing or wrong flags")
}
if !strings.Contains(s, "/tmp/job123/job.sh:/teabot/job.sh:ro,z") {
t.Error("job.sh mount missing or wrong flags")
}
// Claude config copy mounted read-write with relabel.
if !strings.Contains(s, "/tmp/job123/claude:/home/agent/.claude:z") {
t.Error("claude mount missing or wrong flags")
}
// Tea config mounted read-only at the XDG path.
if !strings.Contains(s, "/tmp/job123/tea/config.yml:/home/agent/.config/tea/config.yml:ro,z") {
t.Error("tea config mount missing or wrong flags")
}
}
func TestBuildArgsInjectsGitAndXDGEnv(t *testing.T) {
r := &DockerRunner{SELinuxLabel: "z"}
args := r.buildArgs(baseJob(), "/tmp/j")
checks := map[string]string{
"TEABOT_GIT_USER": "implbot",
"TEABOT_TOKEN": "secret-token",
"TEABOT_GIT_HOST": "git.unkin.net",
"TEABOT_CLONE_URL": "https://git.unkin.net/unkin/teabot.git",
"TEABOT_HOME": "/home/agent",
"XDG_CONFIG_HOME": "/home/agent/.config",
}
for k, v := range checks {
if !hasFlagValue(args, "-e", k+"="+v) {
t.Errorf("missing env -e %s=%s", k, v)
}
}
}
func TestBuildArgsAnthropicEnvOptIn(t *testing.T) {
r := &DockerRunner{SELinuxLabel: "z"}
// Without keys, no ANTHROPIC_* env should be injected.
args := r.buildArgs(baseJob(), "/tmp/j")
if strings.Contains(argsString(args), "ANTHROPIC_API_KEY") {
t.Error("ANTHROPIC_API_KEY injected when unset")
}
// With keys, both are injected.
j := baseJob()
j.AnthropicAPIKey = "sk-test"
j.AnthropicBaseURL = "https://gw.example.com"
args = r.buildArgs(j, "/tmp/j")
if !hasFlagValue(args, "-e", "ANTHROPIC_API_KEY=sk-test") {
t.Error("missing ANTHROPIC_API_KEY env")
}
if !hasFlagValue(args, "-e", "ANTHROPIC_BASE_URL=https://gw.example.com") {
t.Error("missing ANTHROPIC_BASE_URL env")
}
}
func TestBuildArgsOmitsClaudeMountWhenUnset(t *testing.T) {
r := &DockerRunner{SELinuxLabel: "z"}
j := baseJob()
j.ClaudeConfigDir = ""
j.TeaConfigPath = ""
s := argsString(r.buildArgs(j, "/tmp/j"))
if strings.Contains(s, ".claude") {
t.Error("claude mount present despite empty ClaudeConfigDir")
}
if strings.Contains(s, "tea/config.yml") {
t.Error("tea mount present despite empty TeaConfigPath")
}
}
func TestJobScriptIsBash(t *testing.T) {
if !strings.HasPrefix(jobScript, "#!/usr/bin/env bash") {
t.Error("job script missing bash shebang")
}
for _, needed := range []string{"git clone", "claude --print", "credential.helper store", "/teabot/prompt.txt"} {
if !strings.Contains(jobScript, needed) {
t.Errorf("job script missing %q", needed)
}
}
}
// fakeRunner demonstrates the Runner interface is satisfiable without docker.
type fakeRunner struct{ jobs []Job }
func (f *fakeRunner) Run(_ context.Context, j Job) (Result, error) {
f.jobs = append(f.jobs, j)
return Result{ExitCode: 0, Duration: time.Millisecond}, nil
}
func TestRunnerInterfaceSatisfiedByFake(t *testing.T) {
var r Runner = &fakeRunner{}
res, err := r.Run(context.Background(), baseJob())
if err != nil || res.ExitCode != 0 {
t.Fatalf("fake runner: res=%+v err=%v", res, err)
}
}