Files
goclaw/cmd/gateway_cron_test.go
T
MToan 7844df74f5 fix(cron): scope cron context to tenant slug so managed skills resolve (#1378)
Cron job execution set the tenant ID on its context but not the tenant
slug. Tenant-scoped filesystem paths (skills-store, workspace, media via
config.TenantScopedDir) key off the slug and fall back to an id-based
path when it is absent — a different directory than where HTTP/WS skill
upload materialized the files (which sets the slug). As a result a cron
agent turn in a non-master tenant saw NONE of its tenant's managed
skills: skill_search returned 0 results and the agent, unable to run the
skill, produced an ungrounded answer.

Add cronTenantContext() which resolves the tenant slug via TenantStore
and sets both WithTenantID and WithTenantSlug. Master tenant and
nil-store/lookup-failure paths fall back to id-only (prior behavior).
Thread TenantStore into makeCronJobHandler and runCommandCronJob.

Tested: added unit tests for cronTenantContext (slug injected for
non-master; master skips lookup; nil store and lookup error fall back to
id-only). Verified end-to-end on a live tenant: before, a daily-agenda
cron guessed an empty day; after, it read the real event from the DB.

Note: other background executors that build a context from a tenant ID
(e.g. heartbeat) likely share this gap and are worth an audit.
2026-07-07 10:52:17 +07:00

325 lines
10 KiB
Go

package cmd
import (
"context"
"fmt"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func TestCronJobHandlerInjectsPayloadCredentialUserID(t *testing.T) {
wantCredentialUserID := "tenant-user-123"
var gotCredentialUserID string
sched := scheduler.NewScheduler(
scheduler.DefaultLanes(),
scheduler.QueueConfig{
Mode: scheduler.QueueModeQueue,
Cap: 1,
Drop: scheduler.DropOld,
DebounceMs: 0,
MaxConcurrent: 1,
},
func(ctx context.Context, req agent.RunRequest) (*agent.RunResult, error) {
gotCredentialUserID = store.CredentialUserIDFromContext(ctx)
return &agent.RunResult{Content: "ok"}, nil
},
)
defer sched.Stop()
handler := makeCronJobHandler(
sched,
nil,
&config.Config{},
nil,
nil,
nil,
nil,
nil,
nil,
)
result, err := handler(&store.CronJob{
ID: uuid.NewString(),
TenantID: uuid.New(),
Name: "credentialed-report",
AgentID: "reporter",
UserID: "group:telegram:-100123",
Stateless: true,
Payload: store.CronPayload{
Kind: "agent_turn",
Message: "run gh issue list",
CredentialUserID: wantCredentialUserID,
},
})
if err != nil {
t.Fatalf("cron handler returned error: %v", err)
}
if result == nil || result.Content != "ok" {
t.Fatalf("cron result = %#v, want content ok", result)
}
if gotCredentialUserID != wantCredentialUserID {
t.Fatalf("credential user ID in scheduled context = %q, want %q", gotCredentialUserID, wantCredentialUserID)
}
}
func TestCronOutputContainsNoReplySentinel(t *testing.T) {
tests := []struct {
name string
in string
want bool
}{
{name: "exact", in: "NO_REPLY", want: true},
{name: "prefix explanation", in: "NO_REPLY - nothing to report", want: true},
{name: "suffix", in: "No relevant update. NO_REPLY", want: true},
{name: "terminal glued punctuation", in: "This is directed at Bảo Ly Content, not me. I should stay silent.NO_REPLY", want: true},
{name: "standalone suffix after space", in: "This is not for me. NO_REPLY", want: true},
{name: "mid sentence", in: "No changes found. NO_REPLY for this run.", want: true},
{name: "lowercase", in: "no_reply", want: true},
{name: "decorative underscore", in: "NO_REPLY_", want: true},
{name: "glued suffix", in: "NO_REPLYING", want: false},
{name: "glued prefix", in: "XNO_REPLY", want: false},
{name: "empty", in: "", want: false},
{name: "unrelated", in: "no reply needed", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := cronOutputContainsNoReplySentinel(tt.in); got != tt.want {
t.Fatalf("cronOutputContainsNoReplySentinel(%q) = %v, want %v", tt.in, got, tt.want)
}
})
}
}
func TestCronJobHandlerSuppressesNoReplyDelivery(t *testing.T) {
tests := []struct {
name string
content string
wantPublish bool
}{
{name: "normal content", content: "daily report ready", wantPublish: true},
{name: "exact no reply", content: "NO_REPLY", wantPublish: false},
{name: "suffix no reply", content: "No relevant update. NO_REPLY", wantPublish: false},
{name: "prefix no reply", content: "NO_REPLY - nothing to report", wantPublish: false},
{name: "decorative underscore no reply", content: "NO_REPLY_", wantPublish: false},
{name: "glued token still delivers", content: "NO_REPLYING is a different word", wantPublish: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mb := bus.New()
defer mb.Close()
sched := scheduler.NewScheduler(
scheduler.DefaultLanes(),
scheduler.QueueConfig{
Mode: scheduler.QueueModeQueue,
Cap: 1,
Drop: scheduler.DropOld,
DebounceMs: 0,
MaxConcurrent: 1,
},
func(context.Context, agent.RunRequest) (*agent.RunResult, error) {
return &agent.RunResult{Content: tt.content}, nil
},
)
defer sched.Stop()
handler := makeCronJobHandler(
sched,
mb,
&config.Config{},
nil,
nil,
nil,
nil,
nil,
nil,
)
result, err := handler(&store.CronJob{
ID: uuid.NewString(),
TenantID: uuid.New(),
Name: "delivery-report",
AgentID: "reporter",
UserID: "user-1",
Stateless: true,
Deliver: true,
DeliverChannel: "telegram",
DeliverTo: "chat-1",
Payload: store.CronPayload{
Kind: "agent_turn",
Message: "daily report",
},
})
if err != nil {
t.Fatalf("cron handler returned error: %v", err)
}
if result == nil || result.Content != tt.content {
t.Fatalf("cron result = %#v, want content %q", result, tt.content)
}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
got, ok := mb.SubscribeOutbound(ctx)
if !tt.wantPublish {
if ok {
t.Fatalf("unexpected outbound message: %#v", got)
}
return
}
if !ok {
t.Fatal("expected outbound message")
}
if got.Content != tt.content || got.Channel != "telegram" || got.ChatID != "chat-1" {
t.Fatalf("outbound message = %#v, want channel telegram chat chat-1 content %q", got, tt.content)
}
})
}
}
// fakeCronSessionStore records Reset calls. The embedded nil SessionStore
// satisfies the interface; the cron handler only calls Reset/Save.
type fakeCronSessionStore struct {
store.SessionStore
resetCount int
}
func (f *fakeCronSessionStore) Reset(context.Context, string) { f.resetCount++ }
func (f *fakeCronSessionStore) Save(context.Context, string) error { return nil }
// A stateless cron run must start fresh by clearing BOTH the goclaw session
// store and the Claude CLI on-disk session; a stateful run must keep both.
func TestCronJobHandler_StatelessResetsSession(t *testing.T) {
cases := []struct {
name string
stateless bool
wantReset bool
}{
{name: "stateless resets both layers", stateless: true, wantReset: true},
{name: "stateful keeps session", stateless: false, wantReset: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var cliResetKeys []string
orig := cronCLISessionReset
cronCLISessionReset = func(_, key string) { cliResetKeys = append(cliResetKeys, key) }
defer func() { cronCLISessionReset = orig }()
fakeStore := &fakeCronSessionStore{}
sched := scheduler.NewScheduler(
scheduler.DefaultLanes(),
scheduler.QueueConfig{
Mode: scheduler.QueueModeQueue,
Cap: 1,
Drop: scheduler.DropOld,
DebounceMs: 0,
MaxConcurrent: 1,
},
func(context.Context, agent.RunRequest) (*agent.RunResult, error) {
return &agent.RunResult{Content: "ok"}, nil
},
)
defer sched.Stop()
handler := makeCronJobHandler(sched, nil, &config.Config{}, nil, fakeStore, nil, nil, nil, nil)
if _, err := handler(&store.CronJob{
ID: uuid.NewString(),
TenantID: uuid.New(),
Name: "j",
AgentID: "reporter",
UserID: "user-1",
Stateless: tc.stateless,
Payload: store.CronPayload{Kind: "agent_turn", Message: "m"},
}); err != nil {
t.Fatalf("cron handler error: %v", err)
}
if gotStore := fakeStore.resetCount > 0; gotStore != tc.wantReset {
t.Errorf("session store reset called=%v, want %v", gotStore, tc.wantReset)
}
if gotCLI := len(cliResetKeys) > 0; gotCLI != tc.wantReset {
t.Errorf("CLI session reset called=%v, want %v", gotCLI, tc.wantReset)
}
})
}
}
// fakeTenantStore implements only GetTenant; embedding the interface satisfies
// the rest (calling any other method would nil-panic, which none of these tests do).
type fakeTenantStore struct {
store.TenantStore
byID map[uuid.UUID]*store.TenantData
err error
}
func (f *fakeTenantStore) GetTenant(_ context.Context, id uuid.UUID) (*store.TenantData, error) {
if f.err != nil {
return nil, f.err
}
return f.byID[id], nil
}
func TestCronTenantContext_InjectsSlugForNonMasterTenant(t *testing.T) {
tid := uuid.Must(uuid.NewV7())
ts := &fakeTenantStore{byID: map[uuid.UUID]*store.TenantData{
tid: {ID: tid, Slug: "family-pilot"},
}}
ctx := cronTenantContext(context.Background(), ts, tid)
if got := store.TenantIDFromContext(ctx); got != tid {
t.Errorf("tenant id = %v, want %v", got, tid)
}
// The slug is what tenant-scoped skills-store/workspace paths key off; without
// it a cron agent turn sees none of its tenant's managed skills.
if got := store.TenantSlugFromContext(ctx); got != "family-pilot" {
t.Errorf("tenant slug = %q, want %q (skills-store would resolve to the wrong dir)", got, "family-pilot")
}
}
func TestCronTenantContext_MasterTenantNeedsNoSlug(t *testing.T) {
// Master tenant paths resolve to the base dir regardless of slug; the store
// must not even be consulted.
ts := &fakeTenantStore{err: fmt.Errorf("GetTenant must not be called for master")}
ctx := cronTenantContext(context.Background(), ts, store.MasterTenantID)
if got := store.TenantIDFromContext(ctx); got != store.MasterTenantID {
t.Errorf("tenant id = %v, want master", got)
}
}
func TestCronTenantContext_NilStore_TenantIDOnly(t *testing.T) {
tid := uuid.Must(uuid.NewV7())
ctx := cronTenantContext(context.Background(), nil, tid)
if got := store.TenantIDFromContext(ctx); got != tid {
t.Errorf("tenant id = %v, want %v", got, tid)
}
if got := store.TenantSlugFromContext(ctx); got != "" {
t.Errorf("slug = %q, want empty when store is nil", got)
}
}
func TestCronTenantContext_LookupError_FallsBackToIDOnly(t *testing.T) {
tid := uuid.Must(uuid.NewV7())
ts := &fakeTenantStore{err: fmt.Errorf("db down")}
ctx := cronTenantContext(context.Background(), ts, tid)
if got := store.TenantSlugFromContext(ctx); got != "" {
t.Errorf("slug = %q, want empty on lookup error", got)
}
if got := store.TenantIDFromContext(ctx); got != tid {
t.Errorf("tenant id = %v, want %v (must still scope by id)", got, tid)
}
}