package personagen import ( "context" "encoding/json" "fmt" "log/slog" "testing" "time" "git.threesix.ai/jordan/persona-community-5/pkg/persona" "git.threesix.ai/jordan/persona-community-5/pkg/queue" "git.threesix.ai/jordan/persona-community-5/pkg/realtime" ) // ── Mock implementations ───────────────────────────────────────────────────── // mockPersonaStore implements PersonaStore for testing. type mockPersonaStore struct { records map[string]*PersonaRecord } func newMockPersonaStore() *mockPersonaStore { return &mockPersonaStore{records: make(map[string]*PersonaRecord)} } func (m *mockPersonaStore) GetByID(_ context.Context, id string) (*PersonaRecord, error) { rec, ok := m.records[id] if !ok { return nil, ErrPersonaNotFound } // Return a copy to avoid shared mutation. cp := *rec if rec.Tags != nil { cp.Tags = make([]string, len(rec.Tags)) copy(cp.Tags, rec.Tags) } if rec.ImageURLs != nil { cp.ImageURLs = make([]string, len(rec.ImageURLs)) copy(cp.ImageURLs, rec.ImageURLs) } if rec.VideoURLs != nil { cp.VideoURLs = make([]string, len(rec.VideoURLs)) copy(cp.VideoURLs, rec.VideoURLs) } if rec.SpecJSON != nil { cp.SpecJSON = make(json.RawMessage, len(rec.SpecJSON)) copy(cp.SpecJSON, rec.SpecJSON) } return &cp, nil } func (m *mockPersonaStore) Update(_ context.Context, p *PersonaRecord) error { if _, ok := m.records[p.ID]; !ok { return ErrPersonaNotFound } m.records[p.ID] = p return nil } // mockProducer records enqueued jobs. type mockProducer struct { jobs []enqueuedJob } type enqueuedJob struct { jobType string payload map[string]any } var _ queue.Producer = (*mockProducer)(nil) func (m *mockProducer) Enqueue(_ context.Context, jobType string, payload map[string]any) (string, error) { m.jobs = append(m.jobs, enqueuedJob{jobType: jobType, payload: payload}) return fmt.Sprintf("job-%d", len(m.jobs)), nil } func (m *mockProducer) EnqueueWithOptions(_ context.Context, job queue.Job) (string, error) { m.jobs = append(m.jobs, enqueuedJob{jobType: job.Type, payload: job.Payload}) return fmt.Sprintf("job-%d", len(m.jobs)), nil } // mockPublisher records published events. type mockPublisher struct { userEvents []publishedEvent channelEvents []publishedEvent } type publishedEvent struct { target string event *realtime.SSEEvent } var _ realtime.EventPublisher = (*mockPublisher)(nil) func (m *mockPublisher) SendToUser(userID string, event *realtime.SSEEvent) error { m.userEvents = append(m.userEvents, publishedEvent{target: userID, event: event}) return nil } func (m *mockPublisher) SendToChannel(channel string, event *realtime.SSEEvent) error { m.channelEvents = append(m.channelEvents, publishedEvent{target: channel, event: event}) return nil } // ── Tests ──────────────────────────────────────────────────────────────────── func TestStagedQueueHandler_MissingPersonaID(t *testing.T) { handler := StagedQueueHandler(PipelineDeps{}) job := &queue.Job{ ID: "test-job", Type: "persona_generate", Payload: map[string]any{"stage": "spec"}, } err := handler(context.Background(), job) if err == nil { t.Fatal("expected error for missing persona_id") } if err.Error() != "missing persona_id in persona_generate job payload" { t.Errorf("unexpected error: %v", err) } } func TestStagedQueueHandler_MissingStage(t *testing.T) { handler := StagedQueueHandler(PipelineDeps{}) job := &queue.Job{ ID: "test-job", Type: "persona_generate", Payload: map[string]any{"persona_id": "ps_test"}, } err := handler(context.Background(), job) if err == nil { t.Fatal("expected error for missing stage") } if err.Error() != "missing stage in persona_generate job payload" { t.Errorf("unexpected error: %v", err) } } func TestStagedQueueHandler_UnknownStage(t *testing.T) { store := newMockPersonaStore() pub := &mockPublisher{} handler := StagedQueueHandler(PipelineDeps{ Personas: store, Pub: pub, Logger: noopLogger(), }) job := &queue.Job{ ID: "test-job", Type: "persona_generate", Payload: map[string]any{ "persona_id": "ps_test", "stage": "unknown_stage", }, } err := handler(context.Background(), job) if err == nil { t.Fatal("expected error for unknown stage") } } func TestStagedQueueHandler_SpecStage_PersonaNotFound(t *testing.T) { store := newMockPersonaStore() pub := &mockPublisher{} handler := StagedQueueHandler(PipelineDeps{ Personas: store, Pub: pub, Logger: noopLogger(), }) job := &queue.Job{ ID: "test-job", Type: "persona_generate", Payload: map[string]any{ "persona_id": "ps_nonexistent", "stage": StageSpec, }, } err := handler(context.Background(), job) if err == nil { t.Fatal("expected error for persona not found") } } func TestExtractTags(t *testing.T) { spec := &persona.PersonaSpec{ DNA: &persona.DNA{ Identity: persona.IdentityDNA{ Gender: persona.GenderWoman, Ethnicity: persona.EthnicityEastAsian, }, }, Name: persona.NameSpec{First: "Luna"}, Lifestyle: persona.Lifestyle{ FashionSense: persona.FashionSense{ Primary: persona.FashionClassicMinimalist, }, Interests: persona.Interests{ Creative: []string{"photography", "painting"}, Active: []string{"yoga", "hiking"}, }, }, } tags := extractTags(spec) if len(tags) > 8 { t.Errorf("expected at most 8 tags, got %d", len(tags)) } if len(tags) == 0 { t.Error("expected at least some tags") } // Should contain gender. found := false for _, tag := range tags { if tag == "woman" { found = true break } } if !found { t.Error("expected 'woman' tag") } } func TestCountCompletedImages(t *testing.T) { spec := &persona.PersonaSpec{ ImageMatrix: []persona.ImageSpec{ {Position: 1, Status: persona.ImageStatusComplete}, {Position: 2, Status: persona.ImageStatusComplete}, {Position: 3, Status: persona.ImageStatusPending}, {Position: 4, Status: persona.ImageStatusFailed}, }, } count := countCompletedImages(spec) if count != 2 { t.Errorf("expected 2 completed, got %d", count) } } func TestAppendIfMissing(t *testing.T) { tests := []struct { name string slice []string s string expected int }{ {"adds new", []string{"a"}, "b", 2}, {"skips duplicate", []string{"a", "b"}, "a", 2}, {"skips empty", []string{"a"}, "", 1}, {"adds to nil", nil, "a", 1}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := appendIfMissing(tt.slice, tt.s) if len(result) != tt.expected { t.Errorf("expected len %d, got %d", tt.expected, len(result)) } }) } } func TestGenerateHandle(t *testing.T) { // Override time for deterministic output. origNow := now now = func() time.Time { return time.UnixMilli(1234567890) } defer func() { now = origNow }() tests := []struct { name string input string }{ {"simple name", "Luna Shadow"}, {"special chars", "DJ Beats!@#"}, {"unicode", "Café Noir"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handle := generateHandle(tt.input) if handle == "" { t.Error("expected non-empty handle") } if len(handle) > 50 { t.Errorf("handle too long: %d chars", len(handle)) } }) } } func TestPublishJobUpdate(t *testing.T) { pub := &mockPublisher{} publishJobUpdate(pub, "ps_123", StageSpec, "complete", 100, "") if len(pub.channelEvents) != 1 { t.Fatalf("expected 1 channel event, got %d", len(pub.channelEvents)) } event := pub.channelEvents[0] if event.target != "channel:personas" { t.Errorf("expected channel 'channel:personas', got '%s'", event.target) } if event.event.Type != "job_update" { t.Errorf("expected event type 'job_update', got '%s'", event.event.Type) } if event.event.Progress != 100 { t.Errorf("expected progress 100, got %d", event.event.Progress) } result, ok := event.event.Result.(map[string]any) if !ok { t.Fatal("expected Result to be map") } if result["persona_id"] != "ps_123" { t.Errorf("expected persona_id 'ps_123', got %v", result["persona_id"]) } if result["stage"] != StageSpec { t.Errorf("expected stage 'spec', got %v", result["stage"]) } if result["status"] != "complete" { t.Errorf("expected status 'complete', got %v", result["status"]) } } func TestPublishJobUpdate_WithError(t *testing.T) { pub := &mockPublisher{} publishJobUpdate(pub, "ps_123", StageSpec, "error", 0, "something went wrong") if len(pub.channelEvents) != 1 { t.Fatalf("expected 1 channel event, got %d", len(pub.channelEvents)) } event := pub.channelEvents[0] if event.event.Error != "something went wrong" { t.Errorf("expected error message, got '%s'", event.event.Error) } } func TestUnmarshalSpec_EmptyJSON(t *testing.T) { _, err := unmarshalSpec(nil) if err == nil { t.Fatal("expected error for nil spec JSON") } _, err = unmarshalSpec(json.RawMessage{}) if err == nil { t.Fatal("expected error for empty spec JSON") } } func TestUnmarshalSpec_ValidJSON(t *testing.T) { spec := &persona.PersonaSpec{ ID: "ps_test", Name: persona.NameSpec{First: "Luna", Last: "Shadow"}, } data, _ := json.Marshal(spec) result, err := unmarshalSpec(data) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.ID != "ps_test" { t.Errorf("expected ID 'ps_test', got '%s'", result.ID) } if result.Name.First != "Luna" { t.Errorf("expected first name 'Luna', got '%s'", result.Name.First) } } // noopLogger returns a discard logger for tests. func noopLogger() *slog.Logger { return slog.Default() // In test context, logs go to stderr which is fine }