Tests forgeMergePR, ensureLabel, getLabelID, runVerification, flagForReview, autoVerifyAndMerge, fileExists, truncate via mock Forge API. 33 tests covering merge success/conflict/error, label CRUD, and project detection. Co-Authored-By: Virgil <virgil@lethean.io>
509 lines
13 KiB
Go
509 lines
13 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package agentic
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"dappco.re/go/core/forge"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// --- forgeMergePR ---
|
|
|
|
func TestForgeMergePR_Good_Success(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, "POST", r.Method)
|
|
assert.Contains(t, r.URL.Path, "/pulls/42/merge")
|
|
assert.Equal(t, "token test-forge-token", r.Header.Get("Authorization"))
|
|
|
|
var body map[string]any
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
assert.Equal(t, "merge", body["Do"])
|
|
assert.Equal(t, true, body["delete_branch_after_merge"])
|
|
|
|
w.WriteHeader(200)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
s := &PrepSubsystem{
|
|
forgeURL: srv.URL,
|
|
forgeToken: "test-forge-token",
|
|
client: srv.Client(),
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
err := s.forgeMergePR(context.Background(), "core", "test-repo", 42)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func TestForgeMergePR_Good_204Response(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(204) // No Content — also valid success
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
s := &PrepSubsystem{
|
|
forgeURL: srv.URL,
|
|
forgeToken: "test-token",
|
|
client: srv.Client(),
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
err := s.forgeMergePR(context.Background(), "core", "test-repo", 1)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func TestForgeMergePR_Bad_ConflictResponse(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(409)
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"message": "merge conflict",
|
|
})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
s := &PrepSubsystem{
|
|
forgeURL: srv.URL,
|
|
forgeToken: "test-token",
|
|
client: srv.Client(),
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
err := s.forgeMergePR(context.Background(), "core", "test-repo", 1)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "409")
|
|
assert.Contains(t, err.Error(), "merge conflict")
|
|
}
|
|
|
|
func TestForgeMergePR_Bad_ServerError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(500)
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"message": "internal server error",
|
|
})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
s := &PrepSubsystem{
|
|
forgeURL: srv.URL,
|
|
forgeToken: "test-token",
|
|
client: srv.Client(),
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
err := s.forgeMergePR(context.Background(), "core", "test-repo", 1)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "500")
|
|
}
|
|
|
|
func TestForgeMergePR_Bad_NetworkError(t *testing.T) {
|
|
srv := httptest.NewServer(http.NotFoundHandler())
|
|
srv.Close() // close immediately to cause connection error
|
|
|
|
s := &PrepSubsystem{
|
|
forgeURL: srv.URL,
|
|
forgeToken: "test-token",
|
|
client: &http.Client{},
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
err := s.forgeMergePR(context.Background(), "core", "test-repo", 1)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// --- extractPRNumber (additional _Ugly cases) ---
|
|
|
|
func TestExtractPRNumber_Ugly_DoubleSlashEnd(t *testing.T) {
|
|
assert.Equal(t, 0, extractPRNumber("https://forge.lthn.ai/core/go-io/pulls/42/"))
|
|
}
|
|
|
|
func TestExtractPRNumber_Ugly_VeryLargeNumber(t *testing.T) {
|
|
assert.Equal(t, 999999, extractPRNumber("https://forge.lthn.ai/core/go-io/pulls/999999"))
|
|
}
|
|
|
|
func TestExtractPRNumber_Ugly_NegativeNumber(t *testing.T) {
|
|
// atoi of "-5" is -5, parseInt wraps atoi
|
|
assert.Equal(t, -5, extractPRNumber("https://forge.lthn.ai/core/go-io/pulls/-5"))
|
|
}
|
|
|
|
func TestExtractPRNumber_Ugly_ZeroExplicit(t *testing.T) {
|
|
assert.Equal(t, 0, extractPRNumber("https://forge.lthn.ai/core/go-io/pulls/0"))
|
|
}
|
|
|
|
// --- ensureLabel ---
|
|
|
|
func TestEnsureLabel_Good_CreatesLabel(t *testing.T) {
|
|
called := false
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, "POST", r.Method)
|
|
assert.Contains(t, r.URL.Path, "/labels")
|
|
called = true
|
|
|
|
var body map[string]string
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
assert.Equal(t, "needs-review", body["name"])
|
|
assert.Equal(t, "#e11d48", body["color"])
|
|
|
|
w.WriteHeader(201)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
s := &PrepSubsystem{
|
|
forgeURL: srv.URL,
|
|
forgeToken: "test-token",
|
|
client: srv.Client(),
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
s.ensureLabel(context.Background(), "core", "test-repo", "needs-review", "e11d48")
|
|
assert.True(t, called)
|
|
}
|
|
|
|
func TestEnsureLabel_Bad_NetworkError(t *testing.T) {
|
|
srv := httptest.NewServer(http.NotFoundHandler())
|
|
srv.Close()
|
|
|
|
s := &PrepSubsystem{
|
|
forgeURL: srv.URL,
|
|
forgeToken: "test-token",
|
|
client: &http.Client{},
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
// Should not panic
|
|
assert.NotPanics(t, func() {
|
|
s.ensureLabel(context.Background(), "core", "test-repo", "test-label", "abc123")
|
|
})
|
|
}
|
|
|
|
// --- getLabelID ---
|
|
|
|
func TestGetLabelID_Good_Found(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode([]map[string]any{
|
|
{"id": 10, "name": "agentic"},
|
|
{"id": 20, "name": "needs-review"},
|
|
})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
s := &PrepSubsystem{
|
|
forgeURL: srv.URL,
|
|
forgeToken: "test-token",
|
|
client: srv.Client(),
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
id := s.getLabelID(context.Background(), "core", "test-repo", "needs-review")
|
|
assert.Equal(t, 20, id)
|
|
}
|
|
|
|
func TestGetLabelID_Bad_NotFound(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode([]map[string]any{
|
|
{"id": 10, "name": "agentic"},
|
|
})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
s := &PrepSubsystem{
|
|
forgeURL: srv.URL,
|
|
forgeToken: "test-token",
|
|
client: srv.Client(),
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
id := s.getLabelID(context.Background(), "core", "test-repo", "missing-label")
|
|
assert.Equal(t, 0, id)
|
|
}
|
|
|
|
func TestGetLabelID_Bad_NetworkError(t *testing.T) {
|
|
srv := httptest.NewServer(http.NotFoundHandler())
|
|
srv.Close()
|
|
|
|
s := &PrepSubsystem{
|
|
forgeURL: srv.URL,
|
|
forgeToken: "test-token",
|
|
client: &http.Client{},
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
id := s.getLabelID(context.Background(), "core", "test-repo", "any")
|
|
assert.Equal(t, 0, id)
|
|
}
|
|
|
|
// --- runVerification ---
|
|
|
|
func TestRunVerification_Good_NoProjectFile(t *testing.T) {
|
|
dir := t.TempDir() // No go.mod, composer.json, or package.json
|
|
|
|
s := &PrepSubsystem{
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
result := s.runVerification(dir)
|
|
assert.True(t, result.passed)
|
|
assert.Equal(t, "none", result.testCmd)
|
|
}
|
|
|
|
func TestRunVerification_Good_GoProject(t *testing.T) {
|
|
dir := t.TempDir()
|
|
require.True(t, fs.Write(filepath.Join(dir, "go.mod"), "module test").OK)
|
|
|
|
s := &PrepSubsystem{
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
result := s.runVerification(dir)
|
|
assert.Equal(t, "go test ./...", result.testCmd)
|
|
// It will fail because there's no real Go code, but we test the detection path
|
|
}
|
|
|
|
func TestRunVerification_Good_PHPProject(t *testing.T) {
|
|
dir := t.TempDir()
|
|
require.True(t, fs.Write(filepath.Join(dir, "composer.json"), `{"require":{}}`).OK)
|
|
|
|
s := &PrepSubsystem{
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
result := s.runVerification(dir)
|
|
// Will fail (no composer) but detection path is covered
|
|
assert.Contains(t, []string{"composer test", "vendor/bin/pest", "none"}, result.testCmd)
|
|
}
|
|
|
|
func TestRunVerification_Good_NodeProject(t *testing.T) {
|
|
dir := t.TempDir()
|
|
require.True(t, fs.Write(filepath.Join(dir, "package.json"), `{"scripts":{"test":"echo ok"}}`).OK)
|
|
|
|
s := &PrepSubsystem{
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
result := s.runVerification(dir)
|
|
assert.Equal(t, "npm test", result.testCmd)
|
|
}
|
|
|
|
func TestRunVerification_Good_NodeNoTestScript(t *testing.T) {
|
|
dir := t.TempDir()
|
|
require.True(t, fs.Write(filepath.Join(dir, "package.json"), `{"scripts":{}}`).OK)
|
|
|
|
s := &PrepSubsystem{
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
result := s.runVerification(dir)
|
|
assert.True(t, result.passed)
|
|
assert.Equal(t, "none", result.testCmd)
|
|
}
|
|
|
|
// --- fileExists ---
|
|
|
|
func TestFileExists_Good_Exists(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.txt")
|
|
require.True(t, fs.Write(path, "hello").OK)
|
|
|
|
assert.True(t, fileExists(path))
|
|
}
|
|
|
|
func TestFileExists_Bad_NotExists(t *testing.T) {
|
|
assert.False(t, fileExists("/nonexistent/path/file.txt"))
|
|
}
|
|
|
|
func TestFileExists_Bad_IsDirectory(t *testing.T) {
|
|
dir := t.TempDir()
|
|
assert.False(t, fileExists(dir)) // directories are not files
|
|
}
|
|
|
|
// --- autoVerifyAndMerge ---
|
|
|
|
func TestAutoVerifyAndMerge_Bad_NoStatus(t *testing.T) {
|
|
dir := t.TempDir()
|
|
s := &PrepSubsystem{
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
// Should not panic when status.json is missing
|
|
assert.NotPanics(t, func() {
|
|
s.autoVerifyAndMerge(dir)
|
|
})
|
|
}
|
|
|
|
func TestAutoVerifyAndMerge_Bad_NoPRURL(t *testing.T) {
|
|
dir := t.TempDir()
|
|
require.NoError(t, writeStatus(dir, &WorkspaceStatus{
|
|
Status: "completed",
|
|
Repo: "go-io",
|
|
Branch: "agent/fix",
|
|
}))
|
|
|
|
s := &PrepSubsystem{
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
// Should return early — no PR URL
|
|
assert.NotPanics(t, func() {
|
|
s.autoVerifyAndMerge(dir)
|
|
})
|
|
}
|
|
|
|
func TestAutoVerifyAndMerge_Bad_EmptyRepo(t *testing.T) {
|
|
dir := t.TempDir()
|
|
require.NoError(t, writeStatus(dir, &WorkspaceStatus{
|
|
Status: "completed",
|
|
PRURL: "https://forge.test/core/go-io/pulls/1",
|
|
}))
|
|
|
|
s := &PrepSubsystem{
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
assert.NotPanics(t, func() {
|
|
s.autoVerifyAndMerge(dir)
|
|
})
|
|
}
|
|
|
|
func TestAutoVerifyAndMerge_Bad_InvalidPRURL(t *testing.T) {
|
|
dir := t.TempDir()
|
|
require.NoError(t, writeStatus(dir, &WorkspaceStatus{
|
|
Status: "completed",
|
|
Repo: "go-io",
|
|
Branch: "agent/fix",
|
|
PRURL: "not-a-url",
|
|
}))
|
|
|
|
s := &PrepSubsystem{
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
// extractPRNumber returns 0 for invalid URL, so autoVerifyAndMerge returns early
|
|
assert.NotPanics(t, func() {
|
|
s.autoVerifyAndMerge(dir)
|
|
})
|
|
}
|
|
|
|
// --- flagForReview ---
|
|
|
|
func TestFlagForReview_Good_AddsLabel(t *testing.T) {
|
|
labelCalled := false
|
|
commentCalled := false
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == "POST" && containsStr(r.URL.Path, "/labels") {
|
|
labelCalled = true
|
|
if containsStr(r.URL.Path, "/issues/") {
|
|
w.WriteHeader(200) // add label to issue
|
|
} else {
|
|
w.WriteHeader(201) // create label
|
|
}
|
|
return
|
|
}
|
|
if r.Method == "GET" && containsStr(r.URL.Path, "/labels") {
|
|
json.NewEncoder(w).Encode([]map[string]any{
|
|
{"id": 99, "name": "needs-review"},
|
|
})
|
|
return
|
|
}
|
|
if r.Method == "POST" && containsStr(r.URL.Path, "/comments") {
|
|
commentCalled = true
|
|
w.WriteHeader(201)
|
|
return
|
|
}
|
|
w.WriteHeader(200)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
s := &PrepSubsystem{
|
|
forge: forge.NewForge(srv.URL, "test-token"),
|
|
forgeURL: srv.URL,
|
|
forgeToken: "test-token",
|
|
client: srv.Client(),
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
s.flagForReview("core", "test-repo", 42, testFailed)
|
|
assert.True(t, labelCalled)
|
|
assert.True(t, commentCalled)
|
|
}
|
|
|
|
func TestFlagForReview_Good_MergeConflictMessage(t *testing.T) {
|
|
var commentBody string
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == "GET" && containsStr(r.URL.Path, "/labels") {
|
|
json.NewEncoder(w).Encode([]map[string]any{})
|
|
return
|
|
}
|
|
if r.Method == "POST" && containsStr(r.URL.Path, "/comments") {
|
|
var body map[string]string
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
commentBody = body["body"]
|
|
w.WriteHeader(201)
|
|
return
|
|
}
|
|
w.WriteHeader(201) // default for label creation etc
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
s := &PrepSubsystem{
|
|
forge: forge.NewForge(srv.URL, "test-token"),
|
|
forgeURL: srv.URL,
|
|
forgeToken: "test-token",
|
|
client: srv.Client(),
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
|
|
s.flagForReview("core", "test-repo", 1, mergeConflict)
|
|
assert.Contains(t, commentBody, "Merge conflict")
|
|
}
|
|
|
|
// --- truncate ---
|
|
|
|
func TestTruncate_Good_Short(t *testing.T) {
|
|
assert.Equal(t, "hello", truncate("hello", 10))
|
|
}
|
|
|
|
func TestTruncate_Good_Exact(t *testing.T) {
|
|
assert.Equal(t, "hello", truncate("hello", 5))
|
|
}
|
|
|
|
func TestTruncate_Good_Long(t *testing.T) {
|
|
assert.Equal(t, "hel...", truncate("hello world", 3))
|
|
}
|
|
|
|
func TestTruncate_Bad_ZeroMax(t *testing.T) {
|
|
assert.Equal(t, "...", truncate("hello", 0))
|
|
}
|
|
|
|
func TestTruncate_Ugly_EmptyString(t *testing.T) {
|
|
assert.Equal(t, "", truncate("", 10))
|
|
}
|