Files
kandev/apps/backend/internal/task/service/task_activity_test.go
T
2026-07-26 14:18:35 +01:00

379 lines
15 KiB
Go

package service
import (
"context"
"errors"
"testing"
"time"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// fakeActivityProvider resolves a per-session foreground activity so the
// task-level aggregate can be exercised with distinct values per session.
type fakeActivityProvider struct {
byID map[string]v1.ForegroundActivity
counts map[string]int
}
func (f *fakeActivityProvider) ForegroundActivity(sessionID string) v1.ForegroundActivity {
return f.byID[sessionID]
}
func (f *fakeActivityProvider) ActiveSubagentCount(sessionID string) int {
return f.counts[sessionID]
}
// blockingActivityEventBus pauses a generating publication until the test
// releases it.
type blockingActivityEventBus struct {
*MockEventBus
generatingEntered chan struct{}
releaseGenerating chan struct{}
}
func (b *blockingActivityEventBus) Publish(ctx context.Context, subject string, event *bus.Event) error {
data, _ := event.Data.(map[string]interface{})
activity, _ := data["foreground_activity"].(string)
if activity == "generating" {
b.generatingEntered <- struct{}{}
<-b.releaseGenerating
}
return b.MockEventBus.Publish(ctx, subject, event)
}
// failingSessionLister decorates a real SessionRepository but fails the one query
// the task-level aggregate depends on, so the "session set unavailable" path can be
// exercised without disturbing any other repository behavior.
type failingSessionLister struct {
repository.SessionRepository
}
func (failingSessionLister) ListActiveTaskSessionsByTaskID(context.Context, string) ([]*models.TaskSession, error) {
return nil, errors.New("boom: sessions unavailable")
}
func createRunningSession(t *testing.T, ctx context.Context, repo interface {
CreateTaskSession(context.Context, *models.TaskSession) error
}, id, taskID string, state models.TaskSessionState) {
t.Helper()
now := time.Now().UTC()
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
ID: id, TaskID: taskID, State: state, StartedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatalf("CreateTaskSession(%s): %v", id, err)
}
}
func foregroundActivityField(t *testing.T, data map[string]interface{}) interface{} {
t.Helper()
value, ok := data["foreground_activity"]
if !ok {
t.Fatalf("foreground_activity missing from payload: %#v", data)
}
return value
}
func TestRecordTaskActivity_ZeroValueServiceInitializesDedupCache(t *testing.T) {
var svc Service
svc.recordTaskActivity("task-1", v1.ForegroundActivityBackground)
if got := svc.lastTaskActivity["task-1"]; got != v1.ForegroundActivityBackground {
t.Fatalf("recorded activity = %q, want %q", got, v1.ForegroundActivityBackground)
}
}
// TestTaskUpdated_StampsForegroundActivityAggregate covers the task.updated
// payload: it carries the MOST-ACTIVE-WINS aggregate, and emits explicit nil when
// no session is running so a stale background reading is cleared.
func TestTaskUpdated_StampsForegroundActivityAggregate(t *testing.T) {
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
createTaskWithoutRepositories(t, ctx, repo)
createRunningSession(t, ctx, repo, "s1", "task-1", models.TaskSessionStateRunning)
provider := &fakeActivityProvider{byID: map[string]v1.ForegroundActivity{"s1": v1.ForegroundActivityBackground}}
svc.SetForegroundActivityProvider(provider)
task := &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1"}
eventBus.ClearEvents()
svc.PublishTaskUpdated(ctx, task)
if got := foregroundActivityField(t, singlePublishedEventData(t, eventBus)); got != "background" {
t.Fatalf("running background: foreground_activity=%#v, want \"background\"", got)
}
provider.byID["s1"] = v1.ForegroundActivityGenerating
eventBus.ClearEvents()
svc.PublishTaskUpdated(ctx, task)
if got := foregroundActivityField(t, singlePublishedEventData(t, eventBus)); got != "generating" {
t.Fatalf("running generating: foreground_activity=%#v, want \"generating\"", got)
}
}
func TestTaskUpdated_BackgroundActivitySurvivesSettledSession(t *testing.T) {
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
createTaskWithoutRepositories(t, ctx, repo)
createRunningSession(t, ctx, repo, "s1", "task-1", models.TaskSessionStateWaitingForInput)
svc.SetForegroundActivityProvider(&fakeActivityProvider{byID: map[string]v1.ForegroundActivity{"s1": v1.ForegroundActivityBackground}})
eventBus.ClearEvents()
svc.PublishTaskUpdated(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1"})
if got := foregroundActivityField(t, singlePublishedEventData(t, eventBus)); got != "background" {
t.Fatalf("settled background session: foreground_activity=%#v, want background", got)
}
}
// TestPublishTaskActivityIfChanged_EmitsOnlyOnAggregateChange is the core
// live-propagation-fallback behavior: a generating↔background flip emits
// task.updated only when the MOST-ACTIVE-WINS reading actually changes.
func TestPublishTaskActivityIfChanged_EmitsOnlyOnAggregateChange(t *testing.T) {
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
createTaskWithoutRepositories(t, ctx, repo)
createRunningSession(t, ctx, repo, "s1", "task-1", models.TaskSessionStateRunning)
createRunningSession(t, ctx, repo, "s2", "task-1", models.TaskSessionStateRunning)
provider := &fakeActivityProvider{byID: map[string]v1.ForegroundActivity{
"s1": v1.ForegroundActivityGenerating,
"s2": v1.ForegroundActivityGenerating,
}}
svc.SetForegroundActivityProvider(provider)
// First observation: unseen → generating, emits once.
eventBus.ClearEvents()
svc.PublishTaskActivityIfChanged(ctx, "task-1")
if got := foregroundActivityField(t, singlePublishedEventData(t, eventBus)); got != "generating" {
t.Fatalf("first flip: foreground_activity=%#v, want \"generating\"", got)
}
// One session flips to background but the other still generates: aggregate
// stays generating → no emit.
provider.byID["s1"] = v1.ForegroundActivityBackground
eventBus.ClearEvents()
svc.PublishTaskActivityIfChanged(ctx, "task-1")
if events := eventBus.GetPublishedEvents(); len(events) != 0 {
t.Fatalf("aggregate unchanged (still generating) must not emit, got %d events", len(events))
}
// The last generating session flips to background: aggregate now background → emit.
provider.byID["s2"] = v1.ForegroundActivityBackground
eventBus.ClearEvents()
svc.PublishTaskActivityIfChanged(ctx, "task-1")
if got := foregroundActivityField(t, singlePublishedEventData(t, eventBus)); got != "background" {
t.Fatalf("flip to background: foreground_activity=%#v, want \"background\"", got)
}
// Idempotent: calling again with no change does not re-emit.
eventBus.ClearEvents()
svc.PublishTaskActivityIfChanged(ctx, "task-1")
if events := eventBus.GetPublishedEvents(); len(events) != 0 {
t.Fatalf("no change must not re-emit, got %d events", len(events))
}
}
func TestPublishTaskActivityIfChanged_EmitsOnCountOnlyChange(t *testing.T) {
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
createTaskWithoutRepositories(t, ctx, repo)
createRunningSession(t, ctx, repo, "s1", "task-1", models.TaskSessionStateRunning)
createRunningSession(t, ctx, repo, "s2", "task-1", models.TaskSessionStateRunning)
provider := &fakeActivityProvider{
byID: map[string]v1.ForegroundActivity{
"s1": v1.ForegroundActivityGenerating,
"s2": v1.ForegroundActivityGenerating,
},
counts: map[string]int{"s1": 1},
}
svc.SetForegroundActivityProvider(provider)
eventBus.ClearEvents()
svc.PublishTaskActivityIfChanged(ctx, "task-1")
if got := singlePublishedEventData(t, eventBus)["active_subagent_count"]; got != 1 {
t.Fatalf("initial active_subagent_count = %#v, want 1", got)
}
provider.counts["s1"] = 2
eventBus.ClearEvents()
svc.PublishTaskActivityIfChanged(ctx, "task-1")
if got := singlePublishedEventData(t, eventBus)["active_subagent_count"]; got != 2 {
t.Fatalf("count-only active_subagent_count = %#v, want 2", got)
}
eventBus.ClearEvents()
svc.PublishTaskActivityIfChanged(ctx, "task-1")
if events := eventBus.GetPublishedEvents(); len(events) != 0 {
t.Fatalf("unchanged activity and count emitted %d events", len(events))
}
}
func TestPublishTaskActivityIfChanged_OrdersConcurrentAggregatePublications(t *testing.T) {
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
createTaskWithoutRepositories(t, ctx, repo)
createRunningSession(t, ctx, repo, "s1", "task-1", models.TaskSessionStateRunning)
createRunningSession(t, ctx, repo, "s2", "task-1", models.TaskSessionStateRunning)
provider := &fakeActivityProvider{byID: map[string]v1.ForegroundActivity{
"s1": v1.ForegroundActivityBackground,
"s2": v1.ForegroundActivityBackground,
}}
svc.SetForegroundActivityProvider(provider)
svc.PublishTaskActivityIfChanged(ctx, "task-1") // Establish the background baseline.
eventBus.ClearEvents()
blockingBus := &blockingActivityEventBus{
MockEventBus: eventBus,
generatingEntered: make(chan struct{}, 1),
releaseGenerating: make(chan struct{}),
}
svc.eventBus = blockingBus
provider.byID["s1"] = v1.ForegroundActivityGenerating
firstDone := make(chan struct{})
go func() {
svc.PublishTaskActivityIfChanged(ctx, "task-1")
close(firstDone)
}()
<-blockingBus.generatingEntered
provider.byID["s1"] = v1.ForegroundActivityBackground
secondDone := make(chan struct{})
go func() {
svc.PublishTaskActivityIfChanged(ctx, "task-1")
close(secondDone)
}()
// The second caller queues behind the blocked first one and returns. The
// timeout is only a deadlock guard; channels establish the ordering.
select {
case <-secondDone:
case <-time.After(time.Second):
t.Fatal("newer activity publication did not queue behind the older publication")
}
if published := eventBus.GetPublishedEvents(); len(published) != 0 {
t.Fatalf("newer activity publication overtook the blocked older publication: %#v", published)
}
close(blockingBus.releaseGenerating)
<-firstDone
<-secondDone
published := eventBus.GetPublishedEvents()
if len(published) != 2 {
t.Fatalf("published %d task activity events, want 2", len(published))
}
firstData, _ := published[0].Data.(map[string]interface{})
if got := foregroundActivityField(t, firstData); got != "generating" {
t.Fatalf("first activity = %#v, want generating", got)
}
secondData, _ := published[1].Data.(map[string]interface{})
if got := foregroundActivityField(t, secondData); got != "background" {
t.Fatalf("second activity = %#v, want background", got)
}
}
func TestPublishTaskActivityIfChanged_BackgroundToEmptyEmitsExplicitNull(t *testing.T) {
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
createTaskWithoutRepositories(t, ctx, repo)
createRunningSession(t, ctx, repo, "s1", "task-1", models.TaskSessionStateWaitingForInput)
provider := &fakeActivityProvider{byID: map[string]v1.ForegroundActivity{
"s1": v1.ForegroundActivityBackground,
}}
svc.SetForegroundActivityProvider(provider)
eventBus.ClearEvents()
svc.PublishTaskActivityIfChanged(ctx, "task-1")
if got := foregroundActivityField(t, singlePublishedEventData(t, eventBus)); got != "background" {
t.Fatalf("baseline foreground_activity=%#v, want background", got)
}
provider.byID["s1"] = v1.ForegroundActivityGenerating
eventBus.ClearEvents()
svc.PublishTaskActivityIfChanged(ctx, "task-1")
data := singlePublishedEventData(t, eventBus)
value, present := data["foreground_activity"]
if !present || value != nil {
t.Fatalf("background->empty must emit explicit foreground_activity null, got %#v", data)
}
}
func assertForegroundActivityAbsent(t *testing.T, data map[string]interface{}) {
t.Helper()
if v, ok := data["foreground_activity"]; ok {
t.Fatalf("foreground_activity must be omitted when the session set is unavailable, got %#v", v)
}
}
// TestTaskUpdated_OmitsForegroundActivityWhenSessionSetUnavailable locks the safe
// fallback: when the session set can't be loaded
// the aggregate is UNKNOWN, so the event omits the field entirely rather than
// stamping an explicit nil that would clear the client to a coarse "done".
func TestTaskUpdated_OmitsForegroundActivityWhenSessionSetUnavailable(t *testing.T) {
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
createTaskWithoutRepositories(t, ctx, repo)
createRunningSession(t, ctx, repo, "s1", "task-1", models.TaskSessionStateRunning)
svc.SetForegroundActivityProvider(&fakeActivityProvider{byID: map[string]v1.ForegroundActivity{"s1": v1.ForegroundActivityBackground}})
svc.sessions = failingSessionLister{svc.sessions}
eventBus.ClearEvents()
svc.PublishTaskUpdated(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1"})
assertForegroundActivityAbsent(t, singlePublishedEventData(t, eventBus))
}
// TestForegroundActivity_UnavailablePreservesLastKnown proves the unknown path does
// not corrupt the dedup baseline: a failed load neither emits a spurious clear nor
// overwrites the last-known aggregate, so once the session set is readable again the
// unchanged reading does not re-emit.
func TestForegroundActivity_UnavailablePreservesLastKnown(t *testing.T) {
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
createTaskWithoutRepositories(t, ctx, repo)
createRunningSession(t, ctx, repo, "s1", "task-1", models.TaskSessionStateRunning)
svc.SetForegroundActivityProvider(&fakeActivityProvider{byID: map[string]v1.ForegroundActivity{"s1": v1.ForegroundActivityBackground}})
// Establish a known "background" baseline.
eventBus.ClearEvents()
svc.PublishTaskActivityIfChanged(ctx, "task-1")
if got := foregroundActivityField(t, singlePublishedEventData(t, eventBus)); got != "background" {
t.Fatalf("baseline: foreground_activity=%#v, want \"background\"", got)
}
// Session set becomes unavailable: no spurious emit.
realSessions := svc.sessions
svc.sessions = failingSessionLister{realSessions}
eventBus.ClearEvents()
svc.PublishTaskActivityIfChanged(ctx, "task-1")
if events := eventBus.GetPublishedEvents(); len(events) != 0 {
t.Fatalf("unavailable session set must not emit, got %d events", len(events))
}
// Readable again with the same reading: baseline was preserved, so no re-emit.
svc.sessions = realSessions
eventBus.ClearEvents()
svc.PublishTaskActivityIfChanged(ctx, "task-1")
if events := eventBus.GetPublishedEvents(); len(events) != 0 {
t.Fatalf("preserved baseline must not re-emit unchanged reading, got %d events", len(events))
}
}
func TestPublishTaskActivityIfChanged_NoProviderIsNoOp(t *testing.T) {
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
createTaskWithoutRepositories(t, ctx, repo)
eventBus.ClearEvents()
svc.PublishTaskActivityIfChanged(ctx, "task-1")
if events := eventBus.GetPublishedEvents(); len(events) != 0 {
t.Fatalf("no provider wired must not emit, got %d events", len(events))
}
}