diff --git a/services/persona-api/cmd/server/main.go b/services/persona-api/cmd/server/main.go index c14f179..5eacf9a 100644 --- a/services/persona-api/cmd/server/main.go +++ b/services/persona-api/cmd/server/main.go @@ -102,6 +102,7 @@ func main() { // Without DATABASE_URL: in-memory repos + in-process AI (development) exampleRepo := memory.NewExampleRepository() albumRepo := memory.NewAlbumRepository() + var personaRepo port.PersonaRepository var userRepo port.UserRepository var sessionRepo port.SessionRepository var authCodeRepo port.AuthCodeRepository @@ -141,6 +142,7 @@ func main() { sessionRepo = postgres.NewSessionRepository(dbPool.DB) authCodeRepo = postgres.NewAuthCodeRepository(dbPool.DB) mediaRepo = postgres.NewMediaObjectRepository(dbPool.DB) + personaRepo = postgres.NewPersonaRepository(dbPool.DB) // DB-backed queue. jobQueue, jobReader = setupDBQueue(ctx, cfg, dbPool, sseHub, logger) @@ -150,6 +152,7 @@ func main() { sessionRepo = memory.NewSessionRepository() authCodeRepo = memory.NewAuthCodeRepository() mediaRepo = memory.NewMediaRepository() + personaRepo = memory.NewPersonaRepository() jobQueue, jobReader = setupStandaloneQueue(ctx, mediaStore, albumRepo, sseHub, logger) } @@ -201,6 +204,7 @@ func main() { // Create services (business logic) exampleService := service.NewExampleService(exampleRepo, logger) albumService := service.NewAlbumService(albumRepo, jobQueue, logger) + personaService := service.NewPersonaService(personaRepo, jobQueue, sseHub, logger) authService := service.NewAuthService( userRepo, sessionRepo, authCodeRepo, emailSender, cfg.JWTSecret, cfg.RegistrationEnabled, logger, @@ -219,6 +223,7 @@ func main() { ExampleService: exampleService, AuthService: authService, AlbumService: albumService, + PersonaService: personaService, Queue: jobQueue, JobReader: jobReader, SSEHub: sseHub, diff --git a/services/persona-api/cmd/server/migrations/004_create_personas.sql b/services/persona-api/cmd/server/migrations/004_create_personas.sql new file mode 100644 index 0000000..63f8505 --- /dev/null +++ b/services/persona-api/cmd/server/migrations/004_create_personas.sql @@ -0,0 +1,23 @@ +-- 004_create_personas.sql +-- Persona table for AI-generated personas with multi-stage generation pipeline. +-- Compatible with both PostgreSQL (local dev) and CockroachDB (production). + +CREATE TABLE IF NOT EXISTS personas ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + handle TEXT UNIQUE NOT NULL, + gender TEXT NOT NULL, + description TEXT NOT NULL, + tags TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + spec_json JSONB, + anchor_url TEXT, + avatar_url TEXT, + banner_url TEXT, + image_urls TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + video_urls TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_personas_status ON personas (status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_personas_handle ON personas (handle); diff --git a/services/persona-api/internal/adapter/memory/persona.go b/services/persona-api/internal/adapter/memory/persona.go new file mode 100644 index 0000000..5c1fded --- /dev/null +++ b/services/persona-api/internal/adapter/memory/persona.go @@ -0,0 +1,128 @@ +package memory + +import ( + "context" + "encoding/json" + "sort" + "sync" + + "github.com/google/uuid" + + "git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/port" +) + +// Compile-time verification that PersonaRepository implements port.PersonaRepository. +var _ port.PersonaRepository = (*PersonaRepository)(nil) + +// PersonaRepository is a thread-safe in-memory implementation of port.PersonaRepository. +type PersonaRepository struct { + mu sync.RWMutex + personas map[domain.PersonaID]*domain.Persona + handles map[string]domain.PersonaID +} + +// NewPersonaRepository creates a new in-memory persona repository. +func NewPersonaRepository() *PersonaRepository { + return &PersonaRepository{ + personas: make(map[domain.PersonaID]*domain.Persona), + handles: make(map[string]domain.PersonaID), + } +} + +func (r *PersonaRepository) Create(_ context.Context, persona *domain.Persona) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.handles[persona.Handle]; exists { + return domain.ErrDuplicateHandle + } + + if persona.ID.IsZero() { + persona.ID = domain.PersonaID(uuid.New().String()) + } + + r.personas[persona.ID] = copyPersona(persona) + r.handles[persona.Handle] = persona.ID + return nil +} + +func (r *PersonaRepository) GetByID(_ context.Context, id domain.PersonaID) (*domain.Persona, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + p, ok := r.personas[id] + if !ok { + return nil, domain.ErrPersonaNotFound + } + return copyPersona(p), nil +} + +func (r *PersonaRepository) List(_ context.Context, limit, offset int) ([]*domain.Persona, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + all := make([]*domain.Persona, 0, len(r.personas)) + for _, p := range r.personas { + all = append(all, p) + } + sort.Slice(all, func(i, j int) bool { + return all[i].CreatedAt.After(all[j].CreatedAt) + }) + + if offset >= len(all) { + return []*domain.Persona{}, nil + } + end := offset + limit + if end > len(all) { + end = len(all) + } + + result := make([]*domain.Persona, 0, end-offset) + for _, p := range all[offset:end] { + result = append(result, copyPersona(p)) + } + return result, nil +} + +func (r *PersonaRepository) Update(_ context.Context, persona *domain.Persona) error { + r.mu.Lock() + defer r.mu.Unlock() + + existing, ok := r.personas[persona.ID] + if !ok { + return domain.ErrPersonaNotFound + } + + if existing.Handle != persona.Handle { + if _, exists := r.handles[persona.Handle]; exists { + return domain.ErrDuplicateHandle + } + delete(r.handles, existing.Handle) + r.handles[persona.Handle] = persona.ID + } + + r.personas[persona.ID] = copyPersona(persona) + return nil +} + +func copyPersona(p *domain.Persona) *domain.Persona { + cp := *p + if p.Tags != nil { + cp.Tags = make([]string, len(p.Tags)) + copy(cp.Tags, p.Tags) + } + if p.ImageURLs != nil { + cp.ImageURLs = make([]string, len(p.ImageURLs)) + copy(cp.ImageURLs, p.ImageURLs) + } + if p.VideoURLs != nil { + cp.VideoURLs = make([]string, len(p.VideoURLs)) + copy(cp.VideoURLs, p.VideoURLs) + } + if p.SpecJSON != nil { + cp.SpecJSON = make(json.RawMessage, len(p.SpecJSON)) + copy(cp.SpecJSON, p.SpecJSON) + } + return &cp +} diff --git a/services/persona-api/internal/adapter/postgres/persona.go b/services/persona-api/internal/adapter/postgres/persona.go new file mode 100644 index 0000000..82e591b --- /dev/null +++ b/services/persona-api/internal/adapter/postgres/persona.go @@ -0,0 +1,218 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/lib/pq" + + "git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/port" +) + +// Compile-time interface check. +var _ port.PersonaRepository = (*PersonaRepository)(nil) + +// personaRow maps to the personas table. +type personaRow struct { + ID string `db:"id"` + Name string `db:"name"` + Handle string `db:"handle"` + Gender string `db:"gender"` + Description string `db:"description"` + Tags pq.StringArray `db:"tags"` + SpecJSON []byte `db:"spec_json"` + AnchorURL *string `db:"anchor_url"` + AvatarURL *string `db:"avatar_url"` + BannerURL *string `db:"banner_url"` + ImageURLs pq.StringArray `db:"image_urls"` + VideoURLs pq.StringArray `db:"video_urls"` + Status string `db:"status"` + CreatedAt time.Time `db:"created_at"` +} + +func (r *personaRow) toDomain() *domain.Persona { + p := &domain.Persona{ + ID: domain.PersonaID(r.ID), + Name: r.Name, + Handle: r.Handle, + Gender: r.Gender, + Description: r.Description, + Tags: []string(r.Tags), + Status: domain.PersonaStatus(r.Status), + CreatedAt: r.CreatedAt, + ImageURLs: []string(r.ImageURLs), + VideoURLs: []string(r.VideoURLs), + } + if p.Tags == nil { + p.Tags = []string{} + } + if p.ImageURLs == nil { + p.ImageURLs = []string{} + } + if p.VideoURLs == nil { + p.VideoURLs = []string{} + } + if len(r.SpecJSON) > 0 { + p.SpecJSON = json.RawMessage(r.SpecJSON) + } + if r.AnchorURL != nil { + p.AnchorURL = *r.AnchorURL + } + if r.AvatarURL != nil { + p.AvatarURL = *r.AvatarURL + } + if r.BannerURL != nil { + p.BannerURL = *r.BannerURL + } + return p +} + +// PersonaRepository implements port.PersonaRepository with PostgreSQL/CockroachDB. +type PersonaRepository struct { + db *sqlx.DB +} + +// NewPersonaRepository creates a new Postgres-backed persona repository. +func NewPersonaRepository(db *sqlx.DB) *PersonaRepository { + return &PersonaRepository{db: db} +} + +func (r *PersonaRepository) Create(ctx context.Context, persona *domain.Persona) error { + var specJSON []byte + if persona.SpecJSON != nil { + specJSON = []byte(persona.SpecJSON) + } + + var anchorURL, avatarURL, bannerURL *string + if persona.AnchorURL != "" { + anchorURL = &persona.AnchorURL + } + if persona.AvatarURL != "" { + avatarURL = &persona.AvatarURL + } + if persona.BannerURL != "" { + bannerURL = &persona.BannerURL + } + + err := r.db.QueryRowContext(ctx, ` + INSERT INTO personas (name, handle, gender, description, tags, spec_json, anchor_url, avatar_url, banner_url, image_urls, video_urls, status, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING id + `, + persona.Name, + persona.Handle, + persona.Gender, + persona.Description, + pq.StringArray(persona.Tags), + specJSON, + anchorURL, + avatarURL, + bannerURL, + pq.StringArray(persona.ImageURLs), + pq.StringArray(persona.VideoURLs), + string(persona.Status), + persona.CreatedAt, + ).Scan(&persona.ID) + if err != nil { + if isUniqueViolation(err) { + return domain.ErrDuplicateHandle + } + return fmt.Errorf("insert persona: %w", err) + } + return nil +} + +func (r *PersonaRepository) GetByID(ctx context.Context, id domain.PersonaID) (*domain.Persona, error) { + var row personaRow + err := r.db.GetContext(ctx, &row, ` + SELECT id, name, handle, gender, description, tags, spec_json, anchor_url, avatar_url, banner_url, image_urls, video_urls, status, created_at + FROM personas WHERE id = $1 + `, string(id)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrPersonaNotFound + } + return nil, fmt.Errorf("get persona: %w", err) + } + return row.toDomain(), nil +} + +func (r *PersonaRepository) List(ctx context.Context, limit, offset int) ([]*domain.Persona, error) { + var rows []personaRow + err := r.db.SelectContext(ctx, &rows, ` + SELECT id, name, handle, gender, description, tags, spec_json, anchor_url, avatar_url, banner_url, image_urls, video_urls, status, created_at + FROM personas + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + `, limit, offset) + if err != nil { + return nil, fmt.Errorf("list personas: %w", err) + } + + result := make([]*domain.Persona, len(rows)) + for i := range rows { + result[i] = rows[i].toDomain() + } + return result, nil +} + +func (r *PersonaRepository) Update(ctx context.Context, persona *domain.Persona) error { + var specJSON []byte + if persona.SpecJSON != nil { + specJSON = []byte(persona.SpecJSON) + } + + var anchorURL, avatarURL, bannerURL *string + if persona.AnchorURL != "" { + anchorURL = &persona.AnchorURL + } + if persona.AvatarURL != "" { + avatarURL = &persona.AvatarURL + } + if persona.BannerURL != "" { + bannerURL = &persona.BannerURL + } + + result, err := r.db.ExecContext(ctx, ` + UPDATE personas + SET name = $2, handle = $3, gender = $4, description = $5, tags = $6, + spec_json = $7, anchor_url = $8, avatar_url = $9, banner_url = $10, + image_urls = $11, video_urls = $12, status = $13 + WHERE id = $1 + `, + string(persona.ID), + persona.Name, + persona.Handle, + persona.Gender, + persona.Description, + pq.StringArray(persona.Tags), + specJSON, + anchorURL, + avatarURL, + bannerURL, + pq.StringArray(persona.ImageURLs), + pq.StringArray(persona.VideoURLs), + string(persona.Status), + ) + if err != nil { + if isUniqueViolation(err) { + return domain.ErrDuplicateHandle + } + return fmt.Errorf("update persona: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("update persona rows affected: %w", err) + } + if rows == 0 { + return domain.ErrPersonaNotFound + } + return nil +} diff --git a/services/persona-api/internal/api/handlers/persona.go b/services/persona-api/internal/api/handlers/persona.go index 495eba9..415a997 100644 --- a/services/persona-api/internal/api/handlers/persona.go +++ b/services/persona-api/internal/api/handlers/persona.go @@ -1,85 +1,151 @@ package handlers import ( + "errors" "net/http" + "strconv" + + "github.com/go-chi/chi/v5" "git.threesix.ai/jordan/persona-community-5/pkg/app" - "git.threesix.ai/jordan/persona-community-5/pkg/auth" "git.threesix.ai/jordan/persona-community-5/pkg/httperror" "git.threesix.ai/jordan/persona-community-5/pkg/httpresponse" "git.threesix.ai/jordan/persona-community-5/pkg/logging" - "git.threesix.ai/jordan/persona-community-5/pkg/queue" + "git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/service" ) -// Persona handles HTTP requests for persona generation. -// All generation is async: validate request, enqueue job, return 202 with job ID. -// Results are delivered via SSE events to the user's `user:` channel: -// -// - persona_spec_started: LLM pipeline started -// - persona_spec_complete: Persona profile generated -// - persona_image_started: Starting a specific image position -// - persona_image_progress: Image position complete with URL -// - persona_image_complete: All 20 images generated -// - persona_video_started: Starting a video motion type -// - persona_video_complete: Video complete with URL -// - persona_failed: Generation failed (check error field) +// Persona handles HTTP requests for persona CRUD operations. type Persona struct { - queue queue.Producer - jobReader queue.JobReader - logger *logging.Logger + svc *service.PersonaService + logger *logging.Logger } // NewPersona creates a new Persona handler with injected dependencies. -func NewPersona(q queue.Producer, jr queue.JobReader, logger *logging.Logger) *Persona { +func NewPersona(svc *service.PersonaService, logger *logging.Logger) *Persona { return &Persona{ - queue: q, - jobReader: jr, - logger: logger.WithComponent("PersonaHandler"), + svc: svc, + logger: logger.WithComponent("PersonaHandler"), } } -// GeneratePersonaRequest is the request body for persona generation. -type GeneratePersonaRequest struct { - // Description is a natural-language persona concept (required). - // Example: "mysterious woman with dark hair who loves poetry" +// CreatePersonaRequest is the request body for creating a persona. +type CreatePersonaRequest struct { Description string `json:"description" validate:"required,min=3,max=1000"` - - // Gender is the gender identity: "woman", "man", or "non_binary" (required). - Gender string `json:"gender" validate:"required,oneof=woman man non_binary"` - - // Name is an optional name override for the generated persona. - Name string `json:"name"` + Gender string `json:"gender" validate:"required,oneof=woman man non_binary"` + CustomName string `json:"custom_name"` } -// GeneratePersona queues a persona generation job. -// Returns immediately with job ID. Full lifecycle results come via SSE. -// -// Subscribe to SSE channel `user:` at /api/persona-api/events before calling. -// Poll job status at GET /generate/jobs/{id} as a fallback to SSE. -func (h *Persona) GeneratePersona(w http.ResponseWriter, r *http.Request) error { - var req GeneratePersonaRequest +// PersonaResponse is the API representation of a persona. +type PersonaResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Handle string `json:"handle"` + Gender string `json:"gender"` + Description string `json:"description"` + Tags []string `json:"tags"` + SpecJSON any `json:"spec_json,omitempty"` + AnchorURL string `json:"anchor_url,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + BannerURL string `json:"banner_url,omitempty"` + ImageURLs []string `json:"image_urls"` + VideoURLs []string `json:"video_urls"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` +} + +func toPersonaResponse(p *domain.Persona) PersonaResponse { + resp := PersonaResponse{ + ID: p.ID.String(), + Name: p.Name, + Handle: p.Handle, + Gender: p.Gender, + Description: p.Description, + Tags: p.Tags, + AnchorURL: p.AnchorURL, + AvatarURL: p.AvatarURL, + BannerURL: p.BannerURL, + ImageURLs: p.ImageURLs, + VideoURLs: p.VideoURLs, + Status: string(p.Status), + CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + if p.SpecJSON != nil { + resp.SpecJSON = p.SpecJSON + } + return resp +} + +// Create creates a new persona and enqueues a generate_spec job. +// Returns 202 Accepted with the created persona. +func (h *Persona) Create(w http.ResponseWriter, r *http.Request) error { + var req CreatePersonaRequest if err := app.BindAndValidate(r, &req); err != nil { return err } - user := auth.GetUser(r.Context()) - if user == nil { - return httperror.Unauthorized("authentication required") - } - - jobID, err := h.queue.Enqueue(r.Context(), "persona_generate", map[string]any{ - "description": req.Description, - "gender": req.Gender, - "name": req.Name, - "userID": user.ID, - }) + persona, err := h.svc.Create(r.Context(), req.Description, req.Gender, req.CustomName) if err != nil { - h.logger.Error("failed to enqueue persona job", "error", err) - return httperror.Internal("failed to queue persona generation") + return mapPersonaDomainError(err) } - h.logger.Info("persona generation queued", "jobId", jobID, "userID", user.ID) - - httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID}) + httpresponse.Accepted(w, r, toPersonaResponse(persona)) return nil } + +// GetByID returns a single persona by ID. +func (h *Persona) GetByID(w http.ResponseWriter, r *http.Request) error { + id := chi.URLParam(r, "id") + if id == "" { + return httperror.BadRequest("persona ID is required") + } + + persona, err := h.svc.GetByID(r.Context(), domain.PersonaID(id)) + if err != nil { + return mapPersonaDomainError(err) + } + + httpresponse.OK(w, r, toPersonaResponse(persona)) + return nil +} + +// List returns a paginated list of personas. +func (h *Persona) List(w http.ResponseWriter, r *http.Request) error { + limit := 20 + offset := 0 + + if v := r.URL.Query().Get("limit"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil { + limit = parsed + } + } + if v := r.URL.Query().Get("offset"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil { + offset = parsed + } + } + + personas, err := h.svc.List(r.Context(), limit, offset) + if err != nil { + return err + } + + results := make([]PersonaResponse, len(personas)) + for i, p := range personas { + results[i] = toPersonaResponse(p) + } + + httpresponse.OK(w, r, results) + return nil +} + +func mapPersonaDomainError(err error) error { + switch { + case errors.Is(err, domain.ErrPersonaNotFound): + return httperror.NotFound("persona not found") + case errors.Is(err, domain.ErrDuplicateHandle): + return httperror.Conflict("persona with this handle already exists") + default: + return err + } +} diff --git a/services/persona-api/internal/api/handlers/persona_test.go b/services/persona-api/internal/api/handlers/persona_test.go new file mode 100644 index 0000000..808eb04 --- /dev/null +++ b/services/persona-api/internal/api/handlers/persona_test.go @@ -0,0 +1,322 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "git.threesix.ai/jordan/persona-community-5/pkg/app" + "git.threesix.ai/jordan/persona-community-5/pkg/logging" + "git.threesix.ai/jordan/persona-community-5/pkg/queue" + "git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/adapter/memory" + "git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/service" +) + +// mockProducer implements queue.Producer for testing. +type mockProducer struct { + jobs []mockJob +} + +type mockJob 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, mockJob{jobType: jobType, payload: payload}) + return "test-job-id", nil +} + +func (m *mockProducer) EnqueueWithOptions(_ context.Context, job queue.Job) (string, error) { + m.jobs = append(m.jobs, mockJob{jobType: job.Type, payload: job.Payload}) + return "test-job-id", nil +} + +func newTestPersonaHandler() (*Persona, *memory.PersonaRepository, *mockProducer) { + repo := memory.NewPersonaRepository() + q := &mockProducer{} + svc := service.NewPersonaService(repo, q, nil, logging.Nop()) + handler := NewPersona(svc, logging.Nop()) + return handler, repo, q +} + +func TestPersona_Create(t *testing.T) { + handler, _, q := newTestPersonaHandler() + + tests := []struct { + name string + body any + wantStatus int + }{ + { + name: "valid request", + body: CreatePersonaRequest{ + Description: "mysterious woman with dark hair", + Gender: "woman", + }, + wantStatus: http.StatusAccepted, + }, + { + name: "valid request with custom name", + body: CreatePersonaRequest{ + Description: "energetic man who loves music", + Gender: "man", + CustomName: "DJ Beats", + }, + wantStatus: http.StatusAccepted, + }, + { + name: "empty body", + body: nil, + wantStatus: http.StatusBadRequest, + }, + { + name: "missing description", + body: map[string]string{ + "gender": "woman", + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "invalid gender", + body: map[string]string{ + "description": "a persona", + "gender": "invalid", + }, + wantStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := chi.NewRouter() + r.Post("/personas", app.Wrap(handler.Create)) + + var body []byte + if tt.body != nil { + var err error + body, err = json.Marshal(tt.body) + if err != nil { + t.Fatalf("failed to marshal body: %v", err) + } + } + + req := httptest.NewRequest(http.MethodPost, "/personas", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("expected status %d, got %d; body: %s", tt.wantStatus, w.Code, w.Body.String()) + } + }) + } + + // Verify a generate_spec job was enqueued + found := false + for _, j := range q.jobs { + if j.jobType == "generate_spec" { + found = true + if j.payload["stage"] != "spec" { + t.Errorf("expected stage 'spec', got %v", j.payload["stage"]) + } + } + } + if !found { + t.Error("expected generate_spec job to be enqueued") + } +} + +func TestPersona_GetByID(t *testing.T) { + handler, repo, _ := newTestPersonaHandler() + + // Seed data + persona := &domain.Persona{ + Name: "Test Persona", + Handle: "test_persona", + Gender: "woman", + Description: "A test persona", + Tags: []string{}, + ImageURLs: []string{}, + VideoURLs: []string{}, + Status: domain.PersonaStatusPending, + } + _ = repo.Create(context.Background(), persona) + seededID := persona.ID.String() + + tests := []struct { + name string + id string + wantStatus int + }{ + { + name: "existing persona", + id: seededID, + wantStatus: http.StatusOK, + }, + { + name: "not found", + id: "550e8400-e29b-41d4-a716-446655440099", + wantStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := chi.NewRouter() + r.Get("/personas/{id}", app.Wrap(handler.GetByID)) + + req := httptest.NewRequest(http.MethodGet, "/personas/"+tt.id, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("expected status %d, got %d; body: %s", tt.wantStatus, w.Code, w.Body.String()) + } + + if tt.wantStatus == http.StatusOK { + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + data, ok := resp["data"].(map[string]any) + if !ok { + t.Fatal("expected 'data' field in response") + } + if data["id"] != seededID { + t.Errorf("expected id %s, got %v", seededID, data["id"]) + } + if data["name"] != "Test Persona" { + t.Errorf("expected name 'Test Persona', got %v", data["name"]) + } + } + }) + } +} + +func TestPersona_List(t *testing.T) { + handler, repo, _ := newTestPersonaHandler() + + t.Run("empty list", func(t *testing.T) { + r := chi.NewRouter() + r.Get("/personas", app.Wrap(handler.List)) + + req := httptest.NewRequest(http.MethodGet, "/personas", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + data, ok := resp["data"].([]any) + if !ok { + t.Fatal("expected 'data' to be an array") + } + if len(data) != 0 { + t.Errorf("expected 0 items, got %d", len(data)) + } + }) + + // Seed data + for i, name := range []string{"Persona A", "Persona B", "Persona C"} { + p := &domain.Persona{ + Name: name, + Handle: name + "_handle", + Gender: "woman", + Description: "description " + string(rune('A'+i)), + Tags: []string{}, + ImageURLs: []string{}, + VideoURLs: []string{}, + Status: domain.PersonaStatusPending, + } + _ = repo.Create(context.Background(), p) + } + + t.Run("returns all personas", func(t *testing.T) { + r := chi.NewRouter() + r.Get("/personas", app.Wrap(handler.List)) + + req := httptest.NewRequest(http.MethodGet, "/personas", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + data, ok := resp["data"].([]any) + if !ok { + t.Fatal("expected 'data' to be an array") + } + if len(data) != 3 { + t.Errorf("expected 3 items, got %d", len(data)) + } + }) + + t.Run("respects limit parameter", func(t *testing.T) { + r := chi.NewRouter() + r.Get("/personas", app.Wrap(handler.List)) + + req := httptest.NewRequest(http.MethodGet, "/personas?limit=2", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + data, ok := resp["data"].([]any) + if !ok { + t.Fatal("expected 'data' to be an array") + } + if len(data) != 2 { + t.Errorf("expected 2 items, got %d", len(data)) + } + }) + + t.Run("respects offset parameter", func(t *testing.T) { + r := chi.NewRouter() + r.Get("/personas", app.Wrap(handler.List)) + + req := httptest.NewRequest(http.MethodGet, "/personas?offset=2", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + data, ok := resp["data"].([]any) + if !ok { + t.Fatal("expected 'data' to be an array") + } + if len(data) != 1 { + t.Errorf("expected 1 item, got %d", len(data)) + } + }) +} diff --git a/services/persona-api/internal/api/routes.go b/services/persona-api/internal/api/routes.go index 807547c..d1dd99a 100644 --- a/services/persona-api/internal/api/routes.go +++ b/services/persona-api/internal/api/routes.go @@ -35,7 +35,7 @@ func RegisterRoutes(application *app.App, deps *Dependencies) { chatHandler := handlers.NewChat(deps.Queue, deps.SSEHub, logger) mediaHandler := handlers.NewMedia(deps.Store, deps.MediaRepo, logger) albumHandler := handlers.NewAlbum(deps.AlbumService, logger) - personaHandler := handlers.NewPersona(deps.Queue, deps.JobReader, logger) + personaHandler := handlers.NewPersona(deps.PersonaService, logger) // Build and mount OpenAPI spec spec := NewServiceSpec() @@ -164,8 +164,10 @@ func RegisterRoutes(application *app.App, deps *Dependencies) { r.Post("/albums/{id}/shots/{index}", app.Wrap(albumHandler.GenerateShot)) r.Delete("/albums/{id}/shots/{index}", app.Wrap(albumHandler.ResetShot)) - // Persona generation (5-stage LLM + 20 images + 4 videos, all async) - r.Post("/persona/generate", app.Wrap(personaHandler.GeneratePersona)) + // Persona CRUD (Create enqueues generate_spec, returns 202) + r.Post("/personas", app.Wrap(personaHandler.Create)) + r.Get("/personas", app.Wrap(personaHandler.List)) + r.Get("/personas/{id}", app.Wrap(personaHandler.GetByID)) }) }) } @@ -175,6 +177,7 @@ type Dependencies struct { ExampleService *service.ExampleService AuthService *service.AuthService AlbumService *service.AlbumService + PersonaService *service.PersonaService Queue queue.Producer JobReader queue.JobReader SSEHub *realtime.SSEHub diff --git a/services/persona-api/internal/api/spec.go b/services/persona-api/internal/api/spec.go index c95ceca..203643c 100644 --- a/services/persona-api/internal/api/spec.go +++ b/services/persona-api/internal/api/spec.go @@ -8,7 +8,8 @@ func NewServiceSpec() *openapi.OpenAPISpec { WithDescription("REST API for the persona-api service"). WithBearerSecurity("bearer", "JWT authentication token"). WithTag("Health", "Service health endpoints"). - WithTag("Examples", "Example CRUD endpoints") + WithTag("Examples", "Example CRUD endpoints"). + WithTag("Personas", "AI persona generation and management") // Define reusable schemas spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{ @@ -108,5 +109,73 @@ func NewServiceSpec() *openapi.OpenAPISpec { }, }) + // ----- Persona schemas ----- + + spec.WithSchema("Persona", openapi.Object(map[string]openapi.Schema{ + "id": openapi.UUID().WithDescription("Unique identifier"), + "name": openapi.String().WithDescription("Persona display name"), + "handle": openapi.String().WithDescription("Unique URL-safe handle"), + "gender": openapi.String().WithDescription("Gender identity"), + "description": openapi.String().WithDescription("Natural-language persona concept"), + "tags": openapi.Array(openapi.String()).WithDescription("Tags"), + "spec_json": openapi.Object(map[string]openapi.Schema{}, "").WithDescription("Generated persona specification"), + "anchor_url": openapi.String().WithDescription("Anchor image URL"), + "avatar_url": openapi.String().WithDescription("Avatar image URL"), + "banner_url": openapi.String().WithDescription("Banner image URL"), + "image_urls": openapi.Array(openapi.String()).WithDescription("Gallery image URLs"), + "video_urls": openapi.Array(openapi.String()).WithDescription("Video URLs"), + "status": openapi.String().WithDescription("Generation status: pending, generating, complete, failed"), + "created_at": openapi.DateTime().WithDescription("Creation timestamp"), + }, "id", "name", "handle", "gender", "description", "status")) + + spec.WithSchema("CreatePersonaRequest", openapi.Object(map[string]openapi.Schema{ + "description": openapi.StringWithMinMax(3, 1000).WithDescription("Natural-language persona concept"), + "gender": openapi.String().WithDescription("Gender identity: woman, man, or non_binary"), + "custom_name": openapi.String().WithDescription("Optional custom name override"), + }, "description", "gender")) + + // ----- Persona endpoints ----- + + spec.AddPath("/api/persona-api/personas", "post", map[string]any{ + "summary": "Create persona", + "description": "Creates a new persona and enqueues a generate_spec job. Returns 202 Accepted.", + "tags": []string{"Personas"}, + "security": []map[string][]string{{"bearer": {}}}, + "requestBody": openapi.RequestBody(openapi.Ref("CreatePersonaRequest"), true), + "responses": map[string]any{ + "202": openapi.OpResponse("Accepted", openapi.ResponseSchema(openapi.Ref("Persona"))), + "400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()), + "401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()), + "422": openapi.OpResponse("Validation error", openapi.ErrorResponseSchema()), + }, + }) + + spec.AddPath("/api/persona-api/personas", "get", map[string]any{ + "summary": "List personas", + "description": "Returns a paginated list of personas ordered by creation date (newest first).", + "tags": []string{"Personas"}, + "security": []map[string][]string{{"bearer": {}}}, + "parameters": []any{ + openapi.QueryParam("limit", "Maximum number of results (default 20, max 100)", false), + openapi.QueryParam("offset", "Number of results to skip (default 0)", false), + }, + "responses": map[string]any{ + "200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.RefArray("Persona"))), + "401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()), + }, + }) + + spec.AddPath("/api/persona-api/personas/{id}", "get", map[string]any{ + "summary": "Get persona by ID", + "tags": []string{"Personas"}, + "security": []map[string][]string{{"bearer": {}}}, + "parameters": []any{openapi.IDParam()}, + "responses": map[string]any{ + "200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Persona"))), + "401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()), + "404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()), + }, + }) + return spec } diff --git a/services/persona-api/internal/domain/errors.go b/services/persona-api/internal/domain/errors.go index a512fcc..b9511ca 100644 --- a/services/persona-api/internal/domain/errors.go +++ b/services/persona-api/internal/domain/errors.go @@ -33,4 +33,8 @@ var ( ErrNameTooLong = errors.New("name exceeds maximum length") ErrEmailTooLong = errors.New("email exceeds maximum length") ErrInvalidAvatarURL = errors.New("avatar URL must use http or https") + + // Persona errors + ErrPersonaNotFound = errors.New("persona not found") + ErrDuplicateHandle = errors.New("persona with this handle already exists") ) diff --git a/services/persona-api/internal/domain/persona.go b/services/persona-api/internal/domain/persona.go new file mode 100644 index 0000000..fd343ab --- /dev/null +++ b/services/persona-api/internal/domain/persona.go @@ -0,0 +1,65 @@ +package domain + +import ( + "encoding/json" + "time" +) + +// PersonaID is a strongly-typed identifier for personas. +type PersonaID string + +// String returns the string representation of the ID. +func (id PersonaID) String() string { + return string(id) +} + +// IsZero returns true if the ID is empty. +func (id PersonaID) IsZero() bool { + return id == "" +} + +// PersonaStatus represents the current generation state of a persona. +type PersonaStatus string + +const ( + PersonaStatusPending PersonaStatus = "pending" + PersonaStatusGenerating PersonaStatus = "generating" + PersonaStatusComplete PersonaStatus = "complete" + PersonaStatusFailed PersonaStatus = "failed" +) + +// PersonaStage represents a generation pipeline stage. +type PersonaStage string + +const ( + StageSpec PersonaStage = "spec" + StageAnchor PersonaStage = "anchor" + StageAvatar PersonaStage = "avatar" + StageBanner PersonaStage = "banner" + StageGalleryBatch PersonaStage = "gallery_batch" + StageVideo PersonaStage = "video" +) + +// Persona represents an AI-generated persona. +type Persona struct { + ID PersonaID `json:"id"` + Name string `json:"name"` + Handle string `json:"handle"` + Gender string `json:"gender"` + Description string `json:"description"` + Tags []string `json:"tags"` + SpecJSON json.RawMessage `json:"spec_json,omitempty"` + AnchorURL string `json:"anchor_url,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + BannerURL string `json:"banner_url,omitempty"` + ImageURLs []string `json:"image_urls"` + VideoURLs []string `json:"video_urls"` + Status PersonaStatus `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +// PersonaGenerateJob represents a queued persona generation job payload. +type PersonaGenerateJob struct { + PersonaID string `json:"persona_id"` + Stage string `json:"stage"` +} diff --git a/services/persona-api/internal/port/persona.go b/services/persona-api/internal/port/persona.go new file mode 100644 index 0000000..f43443f --- /dev/null +++ b/services/persona-api/internal/port/persona.go @@ -0,0 +1,24 @@ +package port + +import ( + "context" + + "git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/domain" +) + +// PersonaRepository defines the interface for persona persistence operations. +type PersonaRepository interface { + // Create stores a new persona. + Create(ctx context.Context, persona *domain.Persona) error + + // GetByID returns a persona by ID. + // Returns domain.ErrPersonaNotFound if not found. + GetByID(ctx context.Context, id domain.PersonaID) (*domain.Persona, error) + + // List returns personas with pagination. + List(ctx context.Context, limit, offset int) ([]*domain.Persona, error) + + // Update persists changes to an existing persona. + // Returns domain.ErrPersonaNotFound if not found. + Update(ctx context.Context, persona *domain.Persona) error +} diff --git a/services/persona-api/internal/service/persona.go b/services/persona-api/internal/service/persona.go new file mode 100644 index 0000000..8e534e2 --- /dev/null +++ b/services/persona-api/internal/service/persona.go @@ -0,0 +1,141 @@ +package service + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + "git.threesix.ai/jordan/persona-community-5/pkg/logging" + "git.threesix.ai/jordan/persona-community-5/pkg/queue" + "git.threesix.ai/jordan/persona-community-5/pkg/realtime" + "git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/port" +) + +// PersonaService handles persona-related business logic. +type PersonaService struct { + repo port.PersonaRepository + queue queue.Producer + sseHub *realtime.SSEHub + logger *logging.Logger +} + +// NewPersonaService creates a new persona service. +func NewPersonaService(repo port.PersonaRepository, q queue.Producer, hub *realtime.SSEHub, logger *logging.Logger) *PersonaService { + return &PersonaService{ + repo: repo, + queue: q, + sseHub: hub, + logger: logger.WithService("PersonaService"), + } +} + +// Create creates a new persona and enqueues a generate_spec job. +func (s *PersonaService) Create(ctx context.Context, description, gender, customName string) (*domain.Persona, error) { + name := customName + if name == "" { + name = generatePersonaName(description) + } + + handle := generateHandle(name) + now := time.Now().UTC() + + persona := &domain.Persona{ + Name: name, + Handle: handle, + Gender: gender, + Description: description, + Tags: []string{}, + ImageURLs: []string{}, + VideoURLs: []string{}, + Status: domain.PersonaStatusPending, + CreatedAt: now, + } + + if err := s.repo.Create(ctx, persona); err != nil { + return nil, fmt.Errorf("create persona: %w", err) + } + + // Enqueue generate_spec job to kick off the pipeline. + _, err := s.queue.Enqueue(ctx, "generate_spec", map[string]any{ + "persona_id": persona.ID.String(), + "stage": string(domain.StageSpec), + }) + if err != nil { + s.logger.Error("failed to enqueue generate_spec job", "error", err, "persona_id", persona.ID) + // Persona is created but job failed to enqueue — not fatal. + // Caller can retry or a reconciler can pick it up. + } + + s.publishUpdate(persona) + s.logger.Info("persona created", "id", persona.ID, "name", name, "handle", handle) + return persona, nil +} + +// GetByID returns a persona by ID. +func (s *PersonaService) GetByID(ctx context.Context, id domain.PersonaID) (*domain.Persona, error) { + return s.repo.GetByID(ctx, id) +} + +// List returns personas with pagination. +func (s *PersonaService) List(ctx context.Context, limit, offset int) ([]*domain.Persona, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + if offset < 0 { + offset = 0 + } + return s.repo.List(ctx, limit, offset) +} + +// publishUpdate sends a persona_updated SSE event to channel:personas. +func (s *PersonaService) publishUpdate(persona *domain.Persona) { + if s.sseHub == nil { + return + } + s.sseHub.SendToChannel("channel:personas", &realtime.SSEEvent{ + Type: "persona_updated", + Result: persona, + }) +} + +// handleRegex strips non-alphanumeric characters for handle generation. +var handleRegex = regexp.MustCompile(`[^a-z0-9]+`) + +// generateHandle creates a URL-safe handle from a name. +func generateHandle(name string) string { + h := strings.ToLower(strings.TrimSpace(name)) + h = handleRegex.ReplaceAllString(h, "_") + h = strings.Trim(h, "_") + if len(h) > 40 { + h = h[:40] + h = strings.TrimRight(h, "_") + } + // Append timestamp suffix to reduce collisions + suffix := fmt.Sprintf("_%d", time.Now().UnixMilli()%100000) + return h + suffix +} + +// generatePersonaName derives a placeholder name from the description. +func generatePersonaName(description string) string { + words := strings.Fields(description) + if len(words) > 3 { + words = words[:3] + } + // Capitalize first letter of each word + for i, w := range words { + if len(w) > 0 { + words[i] = strings.ToUpper(w[:1]) + w[1:] + } + } + name := strings.Join(words, " ") + if len(name) > 50 { + name = name[:50] + } + return name +} diff --git a/services/persona-api/internal/service/persona_test.go b/services/persona-api/internal/service/persona_test.go new file mode 100644 index 0000000..e694471 --- /dev/null +++ b/services/persona-api/internal/service/persona_test.go @@ -0,0 +1,260 @@ +package service + +import ( + "context" + "testing" + + "git.threesix.ai/jordan/persona-community-5/pkg/logging" + "git.threesix.ai/jordan/persona-community-5/pkg/queue" + "git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/adapter/memory" + "git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/domain" +) + +// mockProducer implements queue.Producer for testing. +type mockProducer struct { + jobs []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, struct { + jobType string + payload map[string]any + }{jobType: jobType, payload: payload}) + return "test-job-id", nil +} + +func (m *mockProducer) EnqueueWithOptions(_ context.Context, job queue.Job) (string, error) { + m.jobs = append(m.jobs, struct { + jobType string + payload map[string]any + }{jobType: job.Type, payload: job.Payload}) + return "test-job-id", nil +} + +func TestPersonaService_Create(t *testing.T) { + repo := memory.NewPersonaRepository() + q := &mockProducer{} + svc := NewPersonaService(repo, q, nil, logging.Nop()) + + t.Run("creates persona with custom name", func(t *testing.T) { + persona, err := svc.Create(context.Background(), "mysterious woman with dark hair", "woman", "Luna Shadow") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if persona.Name != "Luna Shadow" { + t.Errorf("expected name 'Luna Shadow', got '%s'", persona.Name) + } + if persona.ID.IsZero() { + t.Error("expected non-empty ID") + } + if persona.Gender != "woman" { + t.Errorf("expected gender 'woman', got '%s'", persona.Gender) + } + if persona.Status != domain.PersonaStatusPending { + t.Errorf("expected status 'pending', got '%s'", persona.Status) + } + if persona.Handle == "" { + t.Error("expected non-empty handle") + } + }) + + t.Run("creates persona with generated name", func(t *testing.T) { + persona, err := svc.Create(context.Background(), "energetic man who loves music", "man", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if persona.Name == "" { + t.Error("expected auto-generated name") + } + if persona.ID.IsZero() { + t.Error("expected non-empty ID") + } + }) + + t.Run("enqueues generate_spec job", func(t *testing.T) { + found := false + for _, j := range q.jobs { + if j.jobType == "generate_spec" { + found = true + if j.payload["stage"] != "spec" { + t.Errorf("expected stage 'spec', got %v", j.payload["stage"]) + } + if _, ok := j.payload["persona_id"]; !ok { + t.Error("expected persona_id in job payload") + } + } + } + if !found { + t.Error("expected generate_spec job to be enqueued") + } + }) + + t.Run("initializes empty slices", func(t *testing.T) { + persona, err := svc.Create(context.Background(), "test persona for slices", "non_binary", "SliceTest") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if persona.Tags == nil { + t.Error("expected Tags to be initialized (not nil)") + } + if persona.ImageURLs == nil { + t.Error("expected ImageURLs to be initialized (not nil)") + } + if persona.VideoURLs == nil { + t.Error("expected VideoURLs to be initialized (not nil)") + } + }) +} + +func TestPersonaService_GetByID(t *testing.T) { + repo := memory.NewPersonaRepository() + q := &mockProducer{} + svc := NewPersonaService(repo, q, nil, logging.Nop()) + + created, _ := svc.Create(context.Background(), "test persona", "woman", "GetTest") + + t.Run("returns existing persona", func(t *testing.T) { + persona, err := svc.GetByID(context.Background(), created.ID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if persona.Name != "GetTest" { + t.Errorf("expected name 'GetTest', got '%s'", persona.Name) + } + }) + + t.Run("returns not found for missing persona", func(t *testing.T) { + _, err := svc.GetByID(context.Background(), "nonexistent-id") + if err != domain.ErrPersonaNotFound { + t.Errorf("expected ErrPersonaNotFound, got %v", err) + } + }) +} + +func TestPersonaService_List(t *testing.T) { + repo := memory.NewPersonaRepository() + q := &mockProducer{} + svc := NewPersonaService(repo, q, nil, logging.Nop()) + + t.Run("returns empty list initially", func(t *testing.T) { + personas, err := svc.List(context.Background(), 20, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(personas) != 0 { + t.Errorf("expected 0 personas, got %d", len(personas)) + } + }) + + // Create some personas + _, _ = svc.Create(context.Background(), "persona one", "woman", "ListOne") + _, _ = svc.Create(context.Background(), "persona two", "man", "ListTwo") + _, _ = svc.Create(context.Background(), "persona three", "non_binary", "ListThree") + + t.Run("returns all personas", func(t *testing.T) { + personas, err := svc.List(context.Background(), 20, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(personas) != 3 { + t.Errorf("expected 3 personas, got %d", len(personas)) + } + }) + + t.Run("respects limit", func(t *testing.T) { + personas, err := svc.List(context.Background(), 2, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(personas) != 2 { + t.Errorf("expected 2 personas, got %d", len(personas)) + } + }) + + t.Run("clamps negative limit to default", func(t *testing.T) { + personas, err := svc.List(context.Background(), -1, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(personas) != 3 { + t.Errorf("expected 3 personas (default limit), got %d", len(personas)) + } + }) + + t.Run("clamps limit above 100", func(t *testing.T) { + personas, err := svc.List(context.Background(), 200, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(personas) != 3 { + t.Errorf("expected 3 personas (all within max limit), got %d", len(personas)) + } + }) + + t.Run("respects offset", func(t *testing.T) { + personas, err := svc.List(context.Background(), 20, 2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(personas) != 1 { + t.Errorf("expected 1 persona, got %d", len(personas)) + } + }) +} + +func TestGenerateHandle(t *testing.T) { + tests := []struct { + name string + input string + }{ + {name: "simple name", input: "Luna Shadow"}, + {name: "special chars", input: "DJ Beats!@#"}, + {name: "unicode", input: "Café Noir"}, + {name: "long name", input: "This Is A Very Long Name That Exceeds The Maximum Handle Length Allowed"}, + } + + 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 TestGeneratePersonaName(t *testing.T) { + tests := []struct { + name string + desc string + check func(string) bool + }{ + { + name: "short description", + desc: "mysterious woman", + check: func(s string) bool { return s != "" }, + }, + { + name: "long description uses first 3 words", + desc: "mysterious woman with dark hair who loves poetry", + check: func(s string) bool { return s != "" && len(s) <= 50 }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name := generatePersonaName(tt.desc) + if !tt.check(name) { + t.Errorf("unexpected name for desc %q: %q", tt.desc, name) + } + }) + } +} diff --git a/services/persona-api/server b/services/persona-api/server new file mode 100755 index 0000000..c17c63e Binary files /dev/null and b/services/persona-api/server differ