- scheduler: roadmap_sync_jobs, river 개선 및 테스트 추가 - workflow: lifecycle, model, service 수정 및 테스트 보완
241 lines
8.2 KiB
Go
241 lines
8.2 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/riverqueue/river"
|
|
|
|
"github.com/nomadcode/nomadcode-core/internal/roadmapsync"
|
|
"github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline"
|
|
"github.com/nomadcode/nomadcode-core/internal/workflow"
|
|
"github.com/nomadcode/nomadcode-core/internal/workitem"
|
|
)
|
|
|
|
// fakeCreationSyncRunner records the input it was driven with so a worker test
|
|
// can assert the job args were mapped into the orchestrator input.
|
|
type fakeCreationSyncRunner struct {
|
|
called bool
|
|
input roadmapsyncpipeline.SyncCreationInput
|
|
result roadmapsyncpipeline.SyncCreationResult
|
|
err error
|
|
}
|
|
|
|
func (f *fakeCreationSyncRunner) SyncCreation(_ context.Context, in roadmapsyncpipeline.SyncCreationInput) (roadmapsyncpipeline.SyncCreationResult, error) {
|
|
f.called = true
|
|
f.input = in
|
|
return f.result, f.err
|
|
}
|
|
|
|
func sampleCreationSyncArgs() RoadmapCreationSyncJobArgs {
|
|
identity := roadmapsync.Identity{
|
|
Shape: roadmapsync.ShapeMilestone,
|
|
RoadmapMilestonePath: "agent-roadmap/phase/p1/milestones/m1.md",
|
|
Provider: "plane",
|
|
Tenant: "general",
|
|
Project: "nomad",
|
|
WorkItemID: "NOMAD-1",
|
|
}
|
|
return RoadmapCreationSyncJobArgs{
|
|
Expected: identity,
|
|
Ref: workitem.Ref{Provider: "plane", ID: "NOMAD-1"},
|
|
OriginalBody: "<p>body</p>",
|
|
TodoStateID: "todo-state",
|
|
MilestoneMarkdown: "# m",
|
|
ProviderRevision: "rev-1",
|
|
RoadmapRevision: "rev-1",
|
|
}
|
|
}
|
|
|
|
func TestRoadmapCreationSyncWorkerCallsService(t *testing.T) {
|
|
runner := &fakeCreationSyncRunner{
|
|
result: roadmapsyncpipeline.SyncCreationResult{Action: roadmapsyncpipeline.SyncActionProjected},
|
|
}
|
|
worker := &RoadmapCreationSyncWorker{Sync: runner}
|
|
|
|
args := sampleCreationSyncArgs()
|
|
err := worker.Work(context.Background(), &river.Job[RoadmapCreationSyncJobArgs]{Args: args})
|
|
if err != nil {
|
|
t.Fatalf("Work returned error: %v", err)
|
|
}
|
|
if !runner.called {
|
|
t.Fatal("expected orchestrator to be called")
|
|
}
|
|
if runner.input.Expected.WorkItemID != "NOMAD-1" {
|
|
t.Errorf("unexpected work item id: %q", runner.input.Expected.WorkItemID)
|
|
}
|
|
if runner.input.MilestoneMarkdown != "# m" || runner.input.TodoStateID != "todo-state" {
|
|
t.Errorf("job args not mapped into orchestrator input: %#v", runner.input)
|
|
}
|
|
}
|
|
|
|
func TestRoadmapCreationSyncWorkerReturnsServiceError(t *testing.T) {
|
|
wantErr := errors.New("provider failed")
|
|
runner := &fakeCreationSyncRunner{err: wantErr}
|
|
worker := &RoadmapCreationSyncWorker{Sync: runner}
|
|
|
|
err := worker.Work(context.Background(), &river.Job[RoadmapCreationSyncJobArgs]{Args: sampleCreationSyncArgs()})
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatalf("expected service error to propagate (so River retries), got %v", err)
|
|
}
|
|
}
|
|
|
|
type fakeTaskFinalizer struct {
|
|
called bool
|
|
provider string
|
|
id string
|
|
result []byte
|
|
}
|
|
|
|
func (f *fakeTaskFinalizer) CompleteTaskByExternalRef(ctx context.Context, provider, id string, result json.RawMessage) error {
|
|
f.called = true
|
|
f.provider = provider
|
|
f.id = id
|
|
f.result = result
|
|
return nil
|
|
}
|
|
|
|
func TestRoadmapCreationSyncWorkerFinalizesExternalTaskOnProjected(t *testing.T) {
|
|
runner := &fakeCreationSyncRunner{
|
|
result: roadmapsyncpipeline.SyncCreationResult{Action: roadmapsyncpipeline.SyncActionProjected, Reason: "projected successfully"},
|
|
}
|
|
finalizer := &fakeTaskFinalizer{}
|
|
worker := &RoadmapCreationSyncWorker{
|
|
Sync: runner,
|
|
TaskFinalizer: finalizer,
|
|
}
|
|
|
|
args := sampleCreationSyncArgs()
|
|
err := worker.Work(context.Background(), &river.Job[RoadmapCreationSyncJobArgs]{Args: args})
|
|
if err != nil {
|
|
t.Fatalf("Work returned error: %v", err)
|
|
}
|
|
if !runner.called {
|
|
t.Fatal("expected orchestrator to be called")
|
|
}
|
|
if !finalizer.called {
|
|
t.Fatal("expected finalizer to be called on projected outcome")
|
|
}
|
|
if finalizer.provider != "plane" || finalizer.id != "NOMAD-1" {
|
|
t.Errorf("unexpected finalizer arguments: provider=%q, id=%q", finalizer.provider, finalizer.id)
|
|
}
|
|
var resMap map[string]any
|
|
if err := json.Unmarshal(finalizer.result, &resMap); err != nil {
|
|
t.Fatalf("fail to unmarshal finalizer result: %v", err)
|
|
}
|
|
if resMap["action"] != "projected" || resMap["reason"] != "projected successfully" {
|
|
t.Errorf("unexpected finalizer result payload: %v", resMap)
|
|
}
|
|
if resMap["summary"] != "projected successfully" {
|
|
t.Errorf("expected summary payload, got %v", resMap["summary"])
|
|
}
|
|
if resMap[workflow.MetadataKeyAuthoringRunState] != "succeeded" {
|
|
t.Errorf("expected authoring_run_state succeeded, got %v", resMap[workflow.MetadataKeyAuthoringRunState])
|
|
}
|
|
if resMap[workflow.MetadataKeyAuthoringRunUpdatedAt] == nil || resMap[workflow.MetadataKeyAuthoringRunUpdatedAt] == "" {
|
|
t.Error("expected authoring_run_updated_at to be set")
|
|
}
|
|
}
|
|
|
|
func TestRoadmapCreationSyncWorkerFinalizesExternalTaskOnComplete(t *testing.T) {
|
|
runner := &fakeCreationSyncRunner{
|
|
result: roadmapsyncpipeline.SyncCreationResult{Action: roadmapsyncpipeline.SyncActionComplete, Reason: "already complete"},
|
|
}
|
|
finalizer := &fakeTaskFinalizer{}
|
|
worker := &RoadmapCreationSyncWorker{
|
|
Sync: runner,
|
|
TaskFinalizer: finalizer,
|
|
}
|
|
|
|
args := sampleCreationSyncArgs()
|
|
err := worker.Work(context.Background(), &river.Job[RoadmapCreationSyncJobArgs]{Args: args})
|
|
if err != nil {
|
|
t.Fatalf("Work returned error: %v", err)
|
|
}
|
|
if !runner.called {
|
|
t.Fatal("expected orchestrator to be called")
|
|
}
|
|
if !finalizer.called {
|
|
t.Fatal("expected finalizer to be called on complete outcome")
|
|
}
|
|
if finalizer.provider != "plane" || finalizer.id != "NOMAD-1" {
|
|
t.Errorf("unexpected finalizer arguments: provider=%q, id=%q", finalizer.provider, finalizer.id)
|
|
}
|
|
var resMap map[string]any
|
|
if err := json.Unmarshal(finalizer.result, &resMap); err != nil {
|
|
t.Fatalf("fail to unmarshal finalizer result: %v", err)
|
|
}
|
|
if resMap["action"] != "complete" || resMap["reason"] != "already complete" {
|
|
t.Errorf("unexpected finalizer result payload: %v", resMap)
|
|
}
|
|
if resMap["summary"] != "already complete" {
|
|
t.Errorf("expected summary payload, got %v", resMap["summary"])
|
|
}
|
|
if resMap[workflow.MetadataKeyAuthoringRunState] != "succeeded" {
|
|
t.Errorf("expected authoring_run_state succeeded, got %v", resMap[workflow.MetadataKeyAuthoringRunState])
|
|
}
|
|
}
|
|
|
|
func TestRoadmapCreationSyncWorkerDoesNotFinalizeNotReady(t *testing.T) {
|
|
runner := &fakeCreationSyncRunner{
|
|
result: roadmapsyncpipeline.SyncCreationResult{Action: roadmapsyncpipeline.SyncActionNotReady, Reason: "not ready yet"},
|
|
}
|
|
finalizer := &fakeTaskFinalizer{}
|
|
worker := &RoadmapCreationSyncWorker{
|
|
Sync: runner,
|
|
TaskFinalizer: finalizer,
|
|
}
|
|
|
|
args := sampleCreationSyncArgs()
|
|
err := worker.Work(context.Background(), &river.Job[RoadmapCreationSyncJobArgs]{Args: args})
|
|
if err != nil {
|
|
t.Fatalf("Work returned error: %v", err)
|
|
}
|
|
if !runner.called {
|
|
t.Fatal("expected orchestrator to be called")
|
|
}
|
|
if finalizer.called {
|
|
t.Fatal("expected finalizer to NOT be called on not_ready outcome")
|
|
}
|
|
}
|
|
|
|
// lazyPool builds a pgxpool without dialing. pgxpool.New is lazy and only
|
|
// connects on first acquire, so it is safe for constructing the scheduler in a
|
|
// unit test that never starts the client.
|
|
func lazyPool(t *testing.T) *pgxpool.Pool {
|
|
t.Helper()
|
|
pool, err := pgxpool.New(context.Background(), "postgres://user:pass@127.0.0.1:1/db")
|
|
if err != nil {
|
|
t.Fatalf("build lazy pool: %v", err)
|
|
}
|
|
t.Cleanup(pool.Close)
|
|
return pool
|
|
}
|
|
|
|
func TestSchedulerRegistersRoadmapSyncWorkerWhenConfigured(t *testing.T) {
|
|
runner := &fakeCreationSyncRunner{}
|
|
client, err := New(lazyPool(t), nil, nil, nil, nil, runner, nil, 0, nil)
|
|
if err != nil {
|
|
t.Fatalf("New with creation sync runner: %v", err)
|
|
}
|
|
if client == nil {
|
|
t.Fatal("expected non-nil client")
|
|
}
|
|
// The enqueue seam exists regardless of registration; the worker registration
|
|
// is exercised by New succeeding with a non-nil runner (River rejects an
|
|
// unknown job kind only at insert/work time, so construction succeeding plus
|
|
// the worker unit test above cover the wiring without a live DB).
|
|
}
|
|
|
|
func TestSchedulerWithoutRoadmapSyncKeepsTaskWorkerOnlyBehavior(t *testing.T) {
|
|
client, err := New(lazyPool(t), nil, nil, nil, nil, nil, nil, 0, nil)
|
|
if err != nil {
|
|
t.Fatalf("New without creation sync runner: %v", err)
|
|
}
|
|
if client == nil {
|
|
t.Fatal("expected non-nil client")
|
|
}
|
|
}
|