gito/services/core/internal/controlplane/runtime_test.go
toki 90f927612e feat(m-iop-agent-run-bridge): complete commit push policy and revision scan event subtasks
- Archive completed subtask files (PLAN, CODE_REVIEW, logs) to agent-task/archive
- Add postgres_internal_test.go for internal storage tests
- Update worker main.go, runtime, storage, and runner with iop agent run bridge
- Expand test coverage in runtime_test.go, postgres_test.go, storage_test.go, runner_test.go
- Update gito-control-plane notes and roadmap milestone
2026-06-16 22:24:23 +09:00

848 lines
26 KiB
Go

package controlplane
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.toki-labs.com/toki/gito/services/core/internal/config"
"git.toki-labs.com/toki/gito/services/core/internal/core"
"git.toki-labs.com/toki/gito/services/core/internal/events"
"git.toki-labs.com/toki/gito/services/core/internal/gitengine"
"git.toki-labs.com/toki/gito/services/core/internal/protosocket"
"git.toki-labs.com/toki/gito/services/core/internal/storage"
"git.toki-labs.com/toki/gito/services/core/internal/worker"
)
// fakeStore is a shared in-memory implementation of storage.Store for tests.
type fakeStore struct {
watches *fakeBranchWatchStore
cursors *fakeRevisionCursorStore
deliveries *fakeProviderDeliveryStore
}
func newFakeStore() *fakeStore {
return &fakeStore{
watches: &fakeBranchWatchStore{},
cursors: &fakeRevisionCursorStore{},
deliveries: &fakeProviderDeliveryStore{records: make(map[string]core.ProviderDelivery)},
}
}
func (f *fakeStore) Ping(_ context.Context) error { return nil }
func (f *fakeStore) Repos() storage.RepoStore { return nil }
func (f *fakeStore) WorkspaceLeases() storage.WorkspaceLeaseStore { return nil }
func (f *fakeStore) Operations() storage.OperationStore { return nil }
func (f *fakeStore) OperationEvents() storage.OperationEventStore { return nil }
func (f *fakeStore) BranchWatches() storage.BranchWatchStore { return f.watches }
func (f *fakeStore) RevisionCursors() storage.RevisionCursorStore { return f.cursors }
func (f *fakeStore) ProviderDeliveries() storage.ProviderDeliveryStore { return f.deliveries }
var _ storage.Store = (*fakeStore)(nil)
// fakeBranchWatchStore
type fakeBranchWatchStore struct {
mu sync.Mutex
watches []core.BranchWatch
}
func (f *fakeBranchWatchStore) UpsertBranchWatch(_ context.Context, watch core.BranchWatch) (core.BranchWatch, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, w := range f.watches {
if w.Provider == watch.Provider && w.RepoID == watch.RepoID && w.Branch == watch.Branch {
return w, nil
}
}
f.watches = append(f.watches, watch)
return watch, nil
}
func (f *fakeBranchWatchStore) FindBranchWatch(_ context.Context, provider, repoID, branch string) (core.BranchWatch, bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, w := range f.watches {
if w.Provider == provider && w.RepoID == repoID && w.Branch == branch {
return w, true, nil
}
}
return core.BranchWatch{}, false, nil
}
func (f *fakeBranchWatchStore) ListBranchWatches(_ context.Context) ([]core.BranchWatch, error) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]core.BranchWatch(nil), f.watches...), nil
}
// fakeRevisionCursorStore
type fakeRevisionCursorStore struct {
mu sync.Mutex
cursors map[string]core.RevisionCursor
}
func (f *fakeRevisionCursorStore) UpsertRevisionCursor(_ context.Context, cursor core.RevisionCursor) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.cursors == nil {
f.cursors = make(map[string]core.RevisionCursor)
}
f.cursors[cursor.RepoID+":"+cursor.Branch] = cursor
return nil
}
func (f *fakeRevisionCursorStore) GetRevisionCursor(_ context.Context, repoID, branch string) (core.RevisionCursor, bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
c, ok := f.cursors[repoID+":"+branch]
return c, ok, nil
}
// fakeProviderDeliveryStore
type fakeProviderDeliveryStore struct {
mu sync.Mutex
records map[string]core.ProviderDelivery
}
func (f *fakeProviderDeliveryStore) RecordOnce(_ context.Context, delivery core.ProviderDelivery) (storage.DeliveryResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
if existing, ok := f.records[delivery.DedupeKey]; ok {
return storage.DeliveryResult{
First: false,
ExistingEventID: existing.EventID,
ExistingCreatedAt: existing.CreatedAt.Truncate(time.Microsecond),
}, nil
}
f.records[delivery.DedupeKey] = delivery
return storage.DeliveryResult{First: true}, nil
}
func (f *fakeProviderDeliveryStore) DeleteDelivery(_ context.Context, provider, dedupeKey string) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.records, dedupeKey)
return nil
}
// Tests
func TestRuntimeRestartPersistsWatchAndCursor(t *testing.T) {
store := newFakeStore()
// Runtime A: register a watch
runtimeA := NewRuntimeWithStore(nil, store)
_, err := runtimeA.RegisterBranchWatch("nomadcode", "develop", "forgejo")
if err != nil {
t.Fatalf("register watch: %v", err)
}
// Runtime B: same store, simulates process restart
runtimeB := NewRuntimeWithStore(nil, store)
revision := core.RevisionEvent{
RepoID: "nomadcode",
Branch: "develop",
Before: "aaa",
After: "bbb",
ObservedAt: time.Now().UTC(),
}
record, matched, err := runtimeB.HandleRevision(context.Background(), "forgejo", "delivery-restart-1", revision)
if err != nil {
t.Fatalf("handle revision: %v", err)
}
if !matched {
t.Fatalf("expected matched=true after restart, got false; record=%+v", record)
}
if record.Duplicate {
t.Fatalf("expected first delivery not to be duplicate")
}
// cursor must be persisted
cursor, ok, err := store.cursors.GetRevisionCursor(context.Background(), "nomadcode", "develop")
if err != nil {
t.Fatalf("get cursor: %v", err)
}
if !ok {
t.Fatal("expected revision cursor to be stored after first delivery")
}
if cursor.Revision != "bbb" {
t.Fatalf("cursor revision: got %q want %q", cursor.Revision, "bbb")
}
}
func TestRuntimeDuplicateDeliveryIsIdempotent(t *testing.T) {
store := newFakeStore()
runtime := NewRuntimeWithStore(nil, store)
_, err := runtime.RegisterBranchWatch("nomadcode", "develop", "forgejo")
if err != nil {
t.Fatalf("register watch: %v", err)
}
revision := core.RevisionEvent{
RepoID: "nomadcode",
Branch: "develop",
Before: "aaa",
After: "bbb",
ObservedAt: time.Now().UTC(),
}
record1, matched1, err := runtime.HandleRevision(context.Background(), "forgejo", "delivery-dup-1", revision)
if err != nil {
t.Fatalf("first handle: %v", err)
}
if !matched1 || record1.Duplicate {
t.Fatalf("first: matched=%v duplicate=%v", matched1, record1.Duplicate)
}
record2, matched2, err := runtime.HandleRevision(context.Background(), "forgejo", "delivery-dup-1", revision)
if err != nil {
t.Fatalf("second handle: %v", err)
}
if !matched2 {
t.Fatalf("duplicate delivery should still return matched=true")
}
if !record2.Duplicate {
t.Fatalf("expected second delivery to be marked as duplicate")
}
// duplicate response must return same event id and created_at as the first
if record2.ID != record1.ID {
t.Fatalf("duplicate event id: got %q want %q", record2.ID, record1.ID)
}
if !record2.CreatedAt.Equal(record1.CreatedAt) {
t.Fatalf("duplicate created_at: got %v want %v", record2.CreatedAt, record1.CreatedAt)
}
// in-memory event log must have exactly one record
evList := runtime.ListEvents()
if len(evList) != 1 {
t.Fatalf("event list count: got %d want 1", len(evList))
}
if evList[0].Type != events.BranchUpdated {
t.Fatalf("event type: got %q", evList[0].Type)
}
}
func TestForgejoPushDuplicateDeliveryIsIdempotent(t *testing.T) {
store := newFakeStore()
router := newRouterWithStore(config.Config{AppEnv: "test", ProtoSocketPath: "/proto-socket"}, nil, store)
registerBranchListener(t, router, `{"repo_id":"nomadcode","branch":"develop","provider":"forgejo"}`)
pushBody := `{
"ref": "refs/heads/develop",
"before": "111",
"after": "222",
"repository": {"name": "nomadcode", "full_name": "toki/nomadcode"},
"commits": [{"id": "222", "modified": ["README.md"]}]
}`
sendPush := func(deliveryID string) map[string]any {
req := httptest.NewRequest(http.MethodPost, "/callbacks/forgejo/push?repo_id=nomadcode", bytes.NewBufferString(pushBody))
req.Header.Set("X-Forgejo-Event", "push")
req.Header.Set("X-Forgejo-Delivery", deliveryID)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
return body
}
first := sendPush("dup-delivery-1")
if first["matched"] != true || first["duplicate"] == true {
t.Fatalf("first push: matched=%v duplicate=%v", first["matched"], first["duplicate"])
}
second := sendPush("dup-delivery-1")
if second["matched"] != true {
t.Fatalf("second push: expected matched=true")
}
if second["duplicate"] != true {
t.Fatalf("second push: expected duplicate=true, got %v", second["duplicate"])
}
// duplicate response event id and created_at must match the first delivery
firstEvent, _ := first["event"].(map[string]any)
secondEvent, _ := second["event"].(map[string]any)
if firstEvent == nil || secondEvent == nil {
t.Fatalf("event missing: first=%v second=%v", first["event"], second["event"])
}
if firstEvent["id"] != secondEvent["id"] {
t.Fatalf("duplicate event id mismatch: first=%v second=%v", firstEvent["id"], secondEvent["id"])
}
if firstEvent["created_at"] != secondEvent["created_at"] {
t.Fatalf("duplicate created_at mismatch: first=%v second=%v", firstEvent["created_at"], secondEvent["created_at"])
}
// /api/events count must not increase on duplicate
evReq := httptest.NewRequest(http.MethodGet, "/api/events", nil)
evRec := httptest.NewRecorder()
router.ServeHTTP(evRec, evReq)
var evBody map[string][]map[string]any
if err := json.NewDecoder(evRec.Body).Decode(&evBody); err != nil {
t.Fatalf("decode events: %v", err)
}
if len(evBody["events"]) != 1 {
t.Fatalf("events count: got %d want 1", len(evBody["events"]))
}
}
func setupGitRepo(t *testing.T) (workdir string, initialRev string) {
tmpDir := t.TempDir()
workdir = filepath.Join(tmpDir, "repo")
if err := os.MkdirAll(workdir, 0755); err != nil {
t.Fatalf("failed to create repo dir: %v", err)
}
runGit(t, workdir, "init")
runGit(t, workdir, "config", "user.name", "Test User")
runGit(t, workdir, "config", "user.email", "test@example.com")
runGit(t, workdir, "config", "commit.gpgsign", "false")
runGit(t, workdir, "branch", "-M", "main")
writeFile(t, filepath.Join(workdir, "README.md"), "# Seed Repository\n")
runGit(t, workdir, "add", "README.md")
runGit(t, workdir, "commit", "-m", "initial commit")
cmd := exec.Command("git", "rev-parse", "HEAD")
cmd.Dir = workdir
out, err := cmd.Output()
if err != nil {
t.Fatalf("failed to get head rev: %v", err)
}
initialRev = strings.TrimSpace(string(out))
return workdir, initialRev
}
func runGit(t *testing.T, dir string, args ...string) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %s failed: %v, output: %s", strings.Join(args, " "), err, string(out))
}
}
func writeFile(t *testing.T, path, content string) {
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("WriteFile %s failed: %v", path, err)
}
}
func TestRuntimeScanBranchRevisionCursorStates(t *testing.T) {
store := newFakeStore()
r := NewRuntimeWithStore(nil, store)
workdir, initialRev := setupGitRepo(t)
opts := ScanRevisionOptions{
RepoID: "myrepo",
Branch: "main",
Workdir: workdir,
Runner: gitengine.CLI{},
}
// 1. First observation: stores cursor, returns no event, matched=false
record, matched, err := r.ScanBranchRevision(context.Background(), opts)
if err != nil {
t.Fatalf("first scan: %v", err)
}
if matched {
t.Fatal("first scan: expected matched=false")
}
if record.ID != "" {
t.Fatalf("first scan: expected empty record, got %+v", record)
}
cursor, ok, err := store.cursors.GetRevisionCursor(context.Background(), "myrepo", "main")
if err != nil {
t.Fatalf("get cursor: %v", err)
}
if !ok {
t.Fatal("first scan: cursor not stored")
}
if cursor.Revision != initialRev {
t.Fatalf("first scan cursor revision: got %q want %q", cursor.Revision, initialRev)
}
// 2. Unchanged revision: no event, updates observed time (or stays unchanged), matched=false
record2, matched2, err := r.ScanBranchRevision(context.Background(), opts)
if err != nil {
t.Fatalf("second scan: %v", err)
}
if matched2 {
t.Fatal("second scan: expected matched=false")
}
if record2.ID != "" {
t.Fatalf("second scan: expected empty record, got %+v", record2)
}
cursor2, ok2, err := store.cursors.GetRevisionCursor(context.Background(), "myrepo", "main")
if err != nil {
t.Fatalf("get cursor2: %v", err)
}
if !ok2 {
t.Fatal("second scan: cursor not found")
}
if cursor2.Revision != initialRev {
t.Fatalf("second scan cursor revision: got %q want %q", cursor2.Revision, initialRev)
}
// 3. Changed revision with watch: stores cursor with new revision, returns event, matched=true
_, err = r.RegisterBranchWatch("myrepo", "main", "forgejo")
if err != nil {
t.Fatalf("register watch: %v", err)
}
writeFile(t, filepath.Join(workdir, "README.md"), "# Seed Repository\n\nupdated\n")
runGit(t, workdir, "add", "README.md")
runGit(t, workdir, "commit", "-m", "update readme")
cmd := exec.Command("git", "rev-parse", "HEAD")
cmd.Dir = workdir
out, err := cmd.Output()
if err != nil {
t.Fatalf("get head: %v", err)
}
afterRev := strings.TrimSpace(string(out))
record3, matched3, err := r.ScanBranchRevision(context.Background(), opts)
if err != nil {
t.Fatalf("third scan: %v", err)
}
if !matched3 {
t.Fatal("third scan: expected matched=true")
}
if record3.Revision.Before != initialRev {
t.Fatalf("third scan record before: got %q want %q", record3.Revision.Before, initialRev)
}
if record3.Revision.After != afterRev {
t.Fatalf("third scan record after: got %q want %q", record3.Revision.After, afterRev)
}
cursor3, ok3, err := store.cursors.GetRevisionCursor(context.Background(), "myrepo", "main")
if err != nil {
t.Fatalf("get cursor3: %v", err)
}
if !ok3 {
t.Fatal("third scan: cursor not found")
}
if cursor3.Revision != afterRev {
t.Fatalf("third scan cursor revision: got %q want %q", cursor3.Revision, afterRev)
}
}
func TestRuntimeScanBranchRevisionBuildsRevisionEvent(t *testing.T) {
store := newFakeStore()
r := NewRuntimeWithStore(nil, store)
workdir, initialRev := setupGitRepo(t)
// Register watch
_, err := r.RegisterBranchWatch("myrepo", "main", "forgejo")
if err != nil {
t.Fatalf("register watch: %v", err)
}
opts := ScanRevisionOptions{
RepoID: "myrepo",
Branch: "main",
Workdir: workdir,
Runner: gitengine.CLI{},
}
// First scan (observation)
_, _, err = r.ScanBranchRevision(context.Background(), opts)
if err != nil {
t.Fatalf("first scan: %v", err)
}
// Make changes
writeFile(t, filepath.Join(workdir, "newfile.txt"), "new file")
writeFile(t, filepath.Join(workdir, "README.md"), "# Seed Repository\n\nupdated\n")
runGit(t, workdir, "add", "newfile.txt", "README.md")
runGit(t, workdir, "commit", "-m", "add newfile and update readme")
cmd := exec.Command("git", "rev-parse", "HEAD")
cmd.Dir = workdir
out, err := cmd.Output()
if err != nil {
t.Fatalf("get head: %v", err)
}
afterRev := strings.TrimSpace(string(out))
// Second scan (changes)
record, matched, err := r.ScanBranchRevision(context.Background(), opts)
if err != nil {
t.Fatalf("second scan: %v", err)
}
if !matched {
t.Fatal("expected matched=true")
}
if record.Revision.Before != initialRev {
t.Fatalf("before: got %q want %q", record.Revision.Before, initialRev)
}
if record.Revision.After != afterRev {
t.Fatalf("after: got %q want %q", record.Revision.After, afterRev)
}
// Verify changed files
if len(record.Revision.ChangedFiles) != 2 {
t.Fatalf("expected 2 changed files, got %d", len(record.Revision.ChangedFiles))
}
filesMap := make(map[string]string)
for _, f := range record.Revision.ChangedFiles {
filesMap[f.Path] = f.ChangeType
}
if filesMap["README.md"] != "modified" {
t.Fatalf("README.md change type: got %q want 'modified'", filesMap["README.md"])
}
if filesMap["newfile.txt"] != "added" {
t.Fatalf("newfile.txt change type: got %q want 'added'", filesMap["newfile.txt"])
}
// Check cursor updated
cursor, ok, err := store.cursors.GetRevisionCursor(context.Background(), "myrepo", "main")
if err != nil || !ok {
t.Fatalf("get cursor: err=%v ok=%v", err, ok)
}
if cursor.Revision != afterRev {
t.Fatalf("cursor: got %q want %q", cursor.Revision, afterRev)
}
// Test no watch event emit
optsNoWatch := ScanRevisionOptions{
RepoID: "nowatch",
Branch: "main",
Workdir: workdir,
Runner: gitengine.CLI{},
}
// Initial scan for nowatch (stores cursor)
_, _, err = r.ScanBranchRevision(context.Background(), optsNoWatch)
if err != nil {
t.Fatalf("first scan nowatch: %v", err)
}
// Add another commit
writeFile(t, filepath.Join(workdir, "another.txt"), "another")
runGit(t, workdir, "add", "another.txt")
runGit(t, workdir, "commit", "-m", "add another")
// Second scan for nowatch (no watch registered)
beforeEvents := len(r.ListEvents())
_, matchedNoWatch, err := r.ScanBranchRevision(context.Background(), optsNoWatch)
if err != nil {
t.Fatalf("second scan nowatch: %v", err)
}
if matchedNoWatch {
t.Fatal("expected matched=false for unwatched repo")
}
afterEvents := len(r.ListEvents())
if afterEvents != beforeEvents {
t.Fatalf("expected events count to remain %d, got %d", beforeEvents, afterEvents)
}
// Verify unwatched cursor advanced to latest head revision
latestHead, err := gitengine.HeadRevision(gitengine.CLI{}, workdir)
if err != nil {
t.Fatalf("failed to get head rev for verify: %v", err)
}
cursorNoWatch, okNoWatch, err := store.cursors.GetRevisionCursor(context.Background(), "nowatch", "main")
if err != nil || !okNoWatch {
t.Fatalf("get nowatch cursor: err=%v ok=%v", err, okNoWatch)
}
if cursorNoWatch.Revision != latestHead {
t.Fatalf("unwatched cursor did not advance: got %q want %q", cursorNoWatch.Revision, latestHead)
}
}
type fakeBroadcaster struct {
envelopes []protosocket.Envelope
err error
}
func (f *fakeBroadcaster) BroadcastEnvelope(ctx context.Context, env protosocket.Envelope) error {
f.envelopes = append(f.envelopes, env)
return f.err
}
func TestRuntimeScanAgentRunRevision(t *testing.T) {
store := newFakeStore()
// Register watch
rWatch := NewRuntimeWithStore(nil, store)
_, err := rWatch.RegisterBranchWatch("myrepo", "main", "forgejo")
if err != nil {
t.Fatalf("register watch: %v", err)
}
// 1. Broadcaster is nil -> ScanAgentRunRevision fails on match (not published)
rNilBroadcaster := NewRuntimeWithStore(nil, store)
// Create mock git workspace
workdir := t.TempDir()
runGit(t, workdir, "init")
runGit(t, workdir, "config", "user.name", "test")
runGit(t, workdir, "config", "user.email", "test@example.com")
// First commit to set HEAD
writeFile(t, filepath.Join(workdir, "README.md"), "hello")
runGit(t, workdir, "add", "README.md")
runGit(t, workdir, "commit", "-m", "first commit")
firstRev, _ := gitengine.HeadRevision(gitengine.CLI{}, workdir)
// Setup initial cursor
err = store.cursors.UpsertRevisionCursor(context.Background(), core.RevisionCursor{
RepoID: "myrepo",
Branch: "main",
Revision: firstRev,
})
if err != nil {
t.Fatalf("upsert cursor: %v", err)
}
// Add second commit
writeFile(t, filepath.Join(workdir, "README.md"), "hello world")
runGit(t, workdir, "add", "README.md")
runGit(t, workdir, "commit", "-m", "second commit")
req := worker.RevisionScanRequest{
Provider: "forgejo",
RepoID: "myrepo",
Branch: "main",
WorkDir: workdir,
Runner: gitengine.CLI{},
ObservedAt: time.Now(),
}
// Scanner with nil broadcaster should return error when matched because event isn't published
_, err = rNilBroadcaster.ScanAgentRunRevision(context.Background(), req)
if err == nil {
t.Fatal("expected error on matched scan with nil broadcaster")
}
if !strings.Contains(err.Error(), "broadcaster is nil") {
t.Fatalf("expected broadcaster is nil error, got %v", err)
}
// 2. Broadcaster is non-nil and succeeds -> ScanAgentRunRevision succeeds
broadcaster := &fakeBroadcaster{}
store2 := newFakeStore()
rWithBroadcaster := NewRuntimeWithStore(broadcaster, store2)
_, err = rWithBroadcaster.RegisterBranchWatch("myrepo", "main", "forgejo")
if err != nil {
t.Fatalf("register watch: %v", err)
}
// We need to reset the cursor back to firstRev to detect changes again
err = store2.cursors.UpsertRevisionCursor(context.Background(), core.RevisionCursor{
RepoID: "myrepo",
Branch: "main",
Revision: firstRev,
})
if err != nil {
t.Fatalf("upsert cursor: %v", err)
}
res, err := rWithBroadcaster.ScanAgentRunRevision(context.Background(), req)
if err != nil {
t.Fatalf("unexpected ScanAgentRunRevision error: %v", err)
}
if !res.Matched {
t.Fatal("expected matched=true")
}
if res.EventID == "" {
t.Fatal("expected non-empty EventID")
}
if len(broadcaster.envelopes) != 1 {
t.Fatalf("expected 1 broadcasted envelope, got %d", len(broadcaster.envelopes))
}
}
type fakeOpEvents struct {
events []storage.OperationEvent
err error
}
func (f *fakeOpEvents) AppendEvent(ctx context.Context, ev storage.OperationEvent) error {
f.events = append(f.events, ev)
return f.err
}
func (f *fakeOpEvents) ListEvents(ctx context.Context, operationID string) ([]storage.OperationEvent, error) {
return nil, nil
}
func (f *fakeOpEvents) ListPendingEvents(ctx context.Context, limit int) ([]storage.OperationEvent, error) {
return nil, nil
}
func (f *fakeOpEvents) MarkPublished(ctx context.Context, eventID string, now time.Time) (storage.OperationEvent, error) {
return storage.OperationEvent{}, nil
}
func TestOutboxBroadcaster(t *testing.T) {
eventsStore := &fakeOpEvents{}
broadcaster := NewOutboxBroadcaster(eventsStore)
// envelope with different channel/action should be skipped
err := broadcaster.BroadcastEnvelope(context.Background(), protosocket.Envelope{
Channel: "repo",
Action: "register",
Payload: map[string]any{},
})
if err != nil {
t.Fatalf("unexpected error on non-matched envelope: %v", err)
}
if len(eventsStore.events) != 0 {
t.Fatalf("expected 0 events appended, got %d", len(eventsStore.events))
}
// envelope with branch.updated channel/action should append to outbox
payload := map[string]any{
"id": "evt-123",
"repo_id": "myrepo",
"branch": "main",
"before": "rev-before",
"after": "rev-after",
}
err = broadcaster.BroadcastEnvelope(context.Background(), protosocket.Envelope{
Channel: "event",
Action: "branch.updated",
Payload: payload,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(eventsStore.events) != 1 {
t.Fatalf("expected 1 event appended, got %d", len(eventsStore.events))
}
ev := eventsStore.events[0]
if ev.OperationID != "" {
t.Errorf("expected empty OperationID for generic event, got %q", ev.OperationID)
}
if ev.Event.ID != "evt-123" {
t.Errorf("expected Event ID 'evt-123', got %q", ev.Event.ID)
}
if ev.Event.Type != events.BranchUpdated {
t.Errorf("expected Event Type %q, got %q", events.BranchUpdated, ev.Event.Type)
}
if ev.Event.Subject != "repo:myrepo" {
t.Errorf("expected Subject 'repo:myrepo', got %q", ev.Event.Subject)
}
}
func TestRuntimeScanAgentRunRevisionPublishFailureDoesNotAdvanceDurableState(t *testing.T) {
store := newFakeStore()
// Register watch
rWatch := NewRuntimeWithStore(nil, store)
_, err := rWatch.RegisterBranchWatch("myrepo", "main", "forgejo")
if err != nil {
t.Fatalf("register watch: %v", err)
}
// Create mock git workspace
workdir := t.TempDir()
runGit(t, workdir, "init")
runGit(t, workdir, "config", "user.name", "test")
runGit(t, workdir, "config", "user.email", "test@example.com")
// First commit to set HEAD
writeFile(t, filepath.Join(workdir, "README.md"), "hello")
runGit(t, workdir, "add", "README.md")
runGit(t, workdir, "commit", "-m", "first commit")
firstRev, _ := gitengine.HeadRevision(gitengine.CLI{}, workdir)
// Setup initial cursor
err = store.cursors.UpsertRevisionCursor(context.Background(), core.RevisionCursor{
RepoID: "myrepo",
Branch: "main",
Revision: firstRev,
})
if err != nil {
t.Fatalf("upsert cursor: %v", err)
}
// Add second commit
writeFile(t, filepath.Join(workdir, "README.md"), "hello world")
runGit(t, workdir, "add", "README.md")
runGit(t, workdir, "commit", "-m", "second commit")
secondRev, _ := gitengine.HeadRevision(gitengine.CLI{}, workdir)
req := worker.RevisionScanRequest{
Provider: "forgejo",
RepoID: "myrepo",
Branch: "main",
WorkDir: workdir,
Runner: gitengine.CLI{},
ObservedAt: time.Now(),
}
// 1. Failing broadcaster
failingBroadcaster := &fakeBroadcaster{err: fmt.Errorf("broadcast failed")}
rFail := NewRuntimeWithStore(failingBroadcaster, store)
_, err = rFail.ScanAgentRunRevision(context.Background(), req)
if err == nil {
t.Fatal("expected error on matched scan with failing broadcaster")
}
// Check that cursor was NOT advanced (remains firstRev)
cur, found, err := store.cursors.GetRevisionCursor(context.Background(), "myrepo", "main")
if err != nil || !found {
t.Fatalf("failed to get cursor: %v, found: %t", err, found)
}
if cur.Revision != firstRev {
t.Errorf("expected cursor to remain at %s, got %s", firstRev, cur.Revision)
}
// Check that dedupe record was deleted/not present so retry is allowed
// dedupeKeyFor(deliveryID, revision) -> revision:myrepo:main:firstRev:secondRev
dedupeKey := fmt.Sprintf("revision:myrepo:main:%s:%s", firstRev, secondRev)
if _, ok := store.deliveries.records[dedupeKey]; ok {
t.Error("expected dedupe delivery record to be deleted after failure")
}
// 2. Succeeding broadcaster (Retry)
successBroadcaster := &fakeBroadcaster{}
rSuccess := NewRuntimeWithStore(successBroadcaster, store)
res, err := rSuccess.ScanAgentRunRevision(context.Background(), req)
if err != nil {
t.Fatalf("unexpected retry error: %v", err)
}
if !res.Matched {
t.Fatal("expected matched=true on retry")
}
// Check that cursor advanced to secondRev
curAfter, found, err := store.cursors.GetRevisionCursor(context.Background(), "myrepo", "main")
if err != nil || !found {
t.Fatalf("failed to get cursor after success: %v, found: %t", err, found)
}
if curAfter.Revision != secondRev {
t.Errorf("expected cursor to advance to %s, got %s", secondRev, curAfter.Revision)
}
// Check that one event was successfully broadcasted
if len(successBroadcaster.envelopes) != 1 {
t.Errorf("expected 1 broadcasted envelope, got %d", len(successBroadcaster.envelopes))
}
}