build: /implement-feature persona-model --requirements 'DB migration in pers...
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
rdev-worker 2026-02-24 07:58:27 +00:00
parent f5f3229364
commit 9c009926d1
14 changed files with 1386 additions and 58 deletions

View File

@ -102,6 +102,7 @@ func main() {
// Without DATABASE_URL: in-memory repos + in-process AI (development) // Without DATABASE_URL: in-memory repos + in-process AI (development)
exampleRepo := memory.NewExampleRepository() exampleRepo := memory.NewExampleRepository()
albumRepo := memory.NewAlbumRepository() albumRepo := memory.NewAlbumRepository()
var personaRepo port.PersonaRepository
var userRepo port.UserRepository var userRepo port.UserRepository
var sessionRepo port.SessionRepository var sessionRepo port.SessionRepository
var authCodeRepo port.AuthCodeRepository var authCodeRepo port.AuthCodeRepository
@ -141,6 +142,7 @@ func main() {
sessionRepo = postgres.NewSessionRepository(dbPool.DB) sessionRepo = postgres.NewSessionRepository(dbPool.DB)
authCodeRepo = postgres.NewAuthCodeRepository(dbPool.DB) authCodeRepo = postgres.NewAuthCodeRepository(dbPool.DB)
mediaRepo = postgres.NewMediaObjectRepository(dbPool.DB) mediaRepo = postgres.NewMediaObjectRepository(dbPool.DB)
personaRepo = postgres.NewPersonaRepository(dbPool.DB)
// DB-backed queue. // DB-backed queue.
jobQueue, jobReader = setupDBQueue(ctx, cfg, dbPool, sseHub, logger) jobQueue, jobReader = setupDBQueue(ctx, cfg, dbPool, sseHub, logger)
@ -150,6 +152,7 @@ func main() {
sessionRepo = memory.NewSessionRepository() sessionRepo = memory.NewSessionRepository()
authCodeRepo = memory.NewAuthCodeRepository() authCodeRepo = memory.NewAuthCodeRepository()
mediaRepo = memory.NewMediaRepository() mediaRepo = memory.NewMediaRepository()
personaRepo = memory.NewPersonaRepository()
jobQueue, jobReader = setupStandaloneQueue(ctx, mediaStore, albumRepo, sseHub, logger) jobQueue, jobReader = setupStandaloneQueue(ctx, mediaStore, albumRepo, sseHub, logger)
} }
@ -201,6 +204,7 @@ func main() {
// Create services (business logic) // Create services (business logic)
exampleService := service.NewExampleService(exampleRepo, logger) exampleService := service.NewExampleService(exampleRepo, logger)
albumService := service.NewAlbumService(albumRepo, jobQueue, logger) albumService := service.NewAlbumService(albumRepo, jobQueue, logger)
personaService := service.NewPersonaService(personaRepo, jobQueue, sseHub, logger)
authService := service.NewAuthService( authService := service.NewAuthService(
userRepo, sessionRepo, authCodeRepo, emailSender, userRepo, sessionRepo, authCodeRepo, emailSender,
cfg.JWTSecret, cfg.RegistrationEnabled, logger, cfg.JWTSecret, cfg.RegistrationEnabled, logger,
@ -219,6 +223,7 @@ func main() {
ExampleService: exampleService, ExampleService: exampleService,
AuthService: authService, AuthService: authService,
AlbumService: albumService, AlbumService: albumService,
PersonaService: personaService,
Queue: jobQueue, Queue: jobQueue,
JobReader: jobReader, JobReader: jobReader,
SSEHub: sseHub, SSEHub: sseHub,

View File

@ -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);

View File

@ -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
}

View File

@ -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
}

View File

@ -1,85 +1,151 @@
package handlers package handlers
import ( import (
"errors"
"net/http" "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/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/httperror"
"git.threesix.ai/jordan/persona-community-5/pkg/httpresponse" "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/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. // Persona handles HTTP requests for persona CRUD operations.
// All generation is async: validate request, enqueue job, return 202 with job ID.
// Results are delivered via SSE events to the user's `user:<userId>` 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)
type Persona struct { type Persona struct {
queue queue.Producer svc *service.PersonaService
jobReader queue.JobReader logger *logging.Logger
logger *logging.Logger
} }
// NewPersona creates a new Persona handler with injected dependencies. // 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{ return &Persona{
queue: q, svc: svc,
jobReader: jr, logger: logger.WithComponent("PersonaHandler"),
logger: logger.WithComponent("PersonaHandler"),
} }
} }
// GeneratePersonaRequest is the request body for persona generation. // CreatePersonaRequest is the request body for creating a persona.
type GeneratePersonaRequest struct { type CreatePersonaRequest struct {
// Description is a natural-language persona concept (required).
// Example: "mysterious woman with dark hair who loves poetry"
Description string `json:"description" validate:"required,min=3,max=1000"` Description string `json:"description" validate:"required,min=3,max=1000"`
Gender string `json:"gender" validate:"required,oneof=woman man non_binary"`
// Gender is the gender identity: "woman", "man", or "non_binary" (required). CustomName string `json:"custom_name"`
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"`
} }
// GeneratePersona queues a persona generation job. // PersonaResponse is the API representation of a persona.
// Returns immediately with job ID. Full lifecycle results come via SSE. type PersonaResponse struct {
// ID string `json:"id"`
// Subscribe to SSE channel `user:<userId>` at /api/persona-api/events before calling. Name string `json:"name"`
// Poll job status at GET /generate/jobs/{id} as a fallback to SSE. Handle string `json:"handle"`
func (h *Persona) GeneratePersona(w http.ResponseWriter, r *http.Request) error { Gender string `json:"gender"`
var req GeneratePersonaRequest 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 { if err := app.BindAndValidate(r, &req); err != nil {
return err return err
} }
user := auth.GetUser(r.Context()) persona, err := h.svc.Create(r.Context(), req.Description, req.Gender, req.CustomName)
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,
})
if err != nil { if err != nil {
h.logger.Error("failed to enqueue persona job", "error", err) return mapPersonaDomainError(err)
return httperror.Internal("failed to queue persona generation")
} }
h.logger.Info("persona generation queued", "jobId", jobID, "userID", user.ID) httpresponse.Accepted(w, r, toPersonaResponse(persona))
httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
return nil 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
}
}

View File

@ -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))
}
})
}

View File

@ -35,7 +35,7 @@ func RegisterRoutes(application *app.App, deps *Dependencies) {
chatHandler := handlers.NewChat(deps.Queue, deps.SSEHub, logger) chatHandler := handlers.NewChat(deps.Queue, deps.SSEHub, logger)
mediaHandler := handlers.NewMedia(deps.Store, deps.MediaRepo, logger) mediaHandler := handlers.NewMedia(deps.Store, deps.MediaRepo, logger)
albumHandler := handlers.NewAlbum(deps.AlbumService, 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 // Build and mount OpenAPI spec
spec := NewServiceSpec() spec := NewServiceSpec()
@ -164,8 +164,10 @@ func RegisterRoutes(application *app.App, deps *Dependencies) {
r.Post("/albums/{id}/shots/{index}", app.Wrap(albumHandler.GenerateShot)) r.Post("/albums/{id}/shots/{index}", app.Wrap(albumHandler.GenerateShot))
r.Delete("/albums/{id}/shots/{index}", app.Wrap(albumHandler.ResetShot)) r.Delete("/albums/{id}/shots/{index}", app.Wrap(albumHandler.ResetShot))
// Persona generation (5-stage LLM + 20 images + 4 videos, all async) // Persona CRUD (Create enqueues generate_spec, returns 202)
r.Post("/persona/generate", app.Wrap(personaHandler.GeneratePersona)) 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 ExampleService *service.ExampleService
AuthService *service.AuthService AuthService *service.AuthService
AlbumService *service.AlbumService AlbumService *service.AlbumService
PersonaService *service.PersonaService
Queue queue.Producer Queue queue.Producer
JobReader queue.JobReader JobReader queue.JobReader
SSEHub *realtime.SSEHub SSEHub *realtime.SSEHub

View File

@ -8,7 +8,8 @@ func NewServiceSpec() *openapi.OpenAPISpec {
WithDescription("REST API for the persona-api service"). WithDescription("REST API for the persona-api service").
WithBearerSecurity("bearer", "JWT authentication token"). WithBearerSecurity("bearer", "JWT authentication token").
WithTag("Health", "Service health endpoints"). 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 // Define reusable schemas
spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{ 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 return spec
} }

View File

@ -33,4 +33,8 @@ var (
ErrNameTooLong = errors.New("name exceeds maximum length") ErrNameTooLong = errors.New("name exceeds maximum length")
ErrEmailTooLong = errors.New("email exceeds maximum length") ErrEmailTooLong = errors.New("email exceeds maximum length")
ErrInvalidAvatarURL = errors.New("avatar URL must use http or https") 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")
) )

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
})
}
}

BIN
services/persona-api/server Executable file

Binary file not shown.