diff --git a/.woodpecker.yml b/.woodpecker.yml index 9552afc..bd16dd4 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -35,6 +35,33 @@ steps: event: push # COMPONENT_STEPS_BELOW + + # Woodpecker CI step for auth-api service + # Add this step to your .woodpecker.yml + + build-auth-api: + image: woodpeckerci/plugin-kaniko + settings: + registry: registry.threesix.ai + repo: slack-verify-1770279078/auth-api + tags: + - latest + - ${CI_COMMIT_SHA:0:8} + context: . + dockerfile: services/auth-api/Dockerfile + cache: true + skip-tls-verify: true + when: + branch: main + event: push + + deploy-auth-api: + image: bitnami/kubectl:latest + commands: + - kubectl set image deployment/slack-verify-1770279078-auth-api auth-api=registry.threesix.ai/slack-verify-1770279078/auth-api:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping" + when: + branch: main + event: push # Do not remove the marker above - component steps are inserted here verify: diff --git a/CLAUDE.md b/CLAUDE.md index 60eee18..eff929f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,4 +76,7 @@ slack-verify-1770279078/ ## Components - +| Component | Type | Path | +|-----------|------|------| +| **auth-api** | API service | `services/auth-api/` | + diff --git a/Procfile b/Procfile index 8e897c6..28a8d7c 100644 --- a/Procfile +++ b/Procfile @@ -1,2 +1,3 @@ # Local development processes # Components will be added below as they're created +auth-api: cd services/auth-api && make run diff --git a/go.work b/go.work index 9ffbefe..f5e42c3 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,5 @@ go 1.23 use ./pkg +use ./services/auth-api // Component modules will be added below diff --git a/services/auth-api/.env.example b/services/auth-api/.env.example new file mode 100644 index 0000000..5b20c75 --- /dev/null +++ b/services/auth-api/.env.example @@ -0,0 +1,21 @@ +# auth-api Service Configuration + +# Server +SERVER_PORT=8001 +SERVER_HOST=0.0.0.0 + +# App +APP_NAME=auth-api +APP_ENVIRONMENT=development +APP_DEBUG=true + +# Logging +LOG_LEVEL=debug +LOG_FORMAT=text + +# Auth (set AUTH_ENABLED=true to require JWT for protected routes) +AUTH_ENABLED=false +JWT_SECRET=dev-secret-change-in-production + +# Database (if needed) +DATABASE_URL=postgres://dev:dev@localhost:5432/slack-verify-1770279078?sslmode=disable diff --git a/services/auth-api/Dockerfile b/services/auth-api/Dockerfile new file mode 100644 index 0000000..a43e527 --- /dev/null +++ b/services/auth-api/Dockerfile @@ -0,0 +1,33 @@ +# Build stage +FROM golang:1.23-alpine AS builder + +RUN apk add --no-cache git + +# Configure Go workspace and private modules +ENV GOPRIVATE=git.threesix.ai/* +ENV GOWORK=/app/go.work + +WORKDIR /app + +# Copy go workspace and all source (workspace deps are local) +# Note: go.work.sum may not exist if no external dependencies have been synced yet +COPY go.work ./ +COPY go.work.su[m] ./ +COPY pkg/ ./pkg/ +COPY services/auth-api/ ./services/auth-api/ + +# Build from workspace root +RUN CGO_ENABLED=0 go build -o /auth-api ./services/auth-api/cmd/server + +# Production stage +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR / + +COPY --from=builder /auth-api /auth-api + +EXPOSE 8001 + +ENTRYPOINT ["/auth-api"] diff --git a/services/auth-api/Makefile b/services/auth-api/Makefile new file mode 100644 index 0000000..606f2f9 --- /dev/null +++ b/services/auth-api/Makefile @@ -0,0 +1,34 @@ +.PHONY: build run test lint fmt docker-build clean + +SERVICE := auth-api +BINARY := bin/$(SERVICE) +GO_MODULE := git.threesix.ai/jordan/slack-verify-1770279078 + +# Build the service binary +build: + go build -o $(BINARY) ./cmd/server + +# Run the service locally +run: + go run ./cmd/server + +# Run tests +test: + go test -v ./... + +# Run linter +lint: + golangci-lint run ./... + +# Format code +fmt: + gofmt -w . + goimports -w -local $(GO_MODULE) . + +# Build Docker image (run from monorepo root) +docker-build: + docker build -t $(SERVICE):latest -f Dockerfile ../.. + +# Clean build artifacts +clean: + rm -rf bin/ diff --git a/services/auth-api/cmd/server/main.go b/services/auth-api/cmd/server/main.go new file mode 100644 index 0000000..26281e1 --- /dev/null +++ b/services/auth-api/cmd/server/main.go @@ -0,0 +1,30 @@ +// Package main is the entry point for the auth-api service. +package main + +import ( + "git.threesix.ai/jordan/slack-verify-1770279078/pkg/app" + "git.threesix.ai/jordan/slack-verify-1770279078/pkg/logging" + "git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api/internal/adapter/memory" + "git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api/internal/api" + "git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api/internal/service" +) + +func main() { + // Create logger + logger := logging.Default() + + // Create adapters (repositories) + exampleRepo := memory.NewExampleRepository() + + // Create services (business logic) + exampleService := service.NewExampleService(exampleRepo, logger) + + // Create application + application := app.New("auth-api", app.WithDefaultPort(8001)) + + // Register routes with dependency injection + api.RegisterRoutes(application, exampleService) + + // Start server + application.Run() +} diff --git a/services/auth-api/component.yaml b/services/auth-api/component.yaml new file mode 100644 index 0000000..f445199 --- /dev/null +++ b/services/auth-api/component.yaml @@ -0,0 +1,9 @@ +name: auth-api +type: service +port: 8001 +path: services/auth-api +dependencies: [] +# Add dependencies as needed: +# - postgres +# - redis +# - other-service diff --git a/services/auth-api/go.mod b/services/auth-api/go.mod new file mode 100644 index 0000000..20ac0e2 --- /dev/null +++ b/services/auth-api/go.mod @@ -0,0 +1,8 @@ +module git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api + +go 1.23 + +require git.threesix.ai/jordan/slack-verify-1770279078/pkg v0.0.0 + +// Use local workspace modules (for Docker builds without go.work) +replace git.threesix.ai/jordan/slack-verify-1770279078/pkg => ../../pkg diff --git a/services/auth-api/go.sum b/services/auth-api/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/services/auth-api/internal/adapter/memory/example.go b/services/auth-api/internal/adapter/memory/example.go new file mode 100644 index 0000000..30fa58a --- /dev/null +++ b/services/auth-api/internal/adapter/memory/example.go @@ -0,0 +1,106 @@ +// Package memory provides in-memory implementations of repository interfaces. +// Useful for development, testing, and prototyping. +package memory + +import ( + "context" + "sync" + + "git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api/internal/domain" + "git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api/internal/port" +) + +// Compile-time verification that ExampleRepository implements port.ExampleRepository. +var _ port.ExampleRepository = (*ExampleRepository)(nil) + +// ExampleRepository is a thread-safe in-memory implementation of port.ExampleRepository. +type ExampleRepository struct { + mu sync.RWMutex + examples map[domain.ExampleID]*domain.Example +} + +// NewExampleRepository creates a new in-memory example repository. +func NewExampleRepository() *ExampleRepository { + return &ExampleRepository{ + examples: make(map[domain.ExampleID]*domain.Example), + } +} + +// List returns all examples. +func (r *ExampleRepository) List(ctx context.Context) ([]domain.Example, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]domain.Example, 0, len(r.examples)) + for _, e := range r.examples { + result = append(result, *e) + } + return result, nil +} + +// Get returns an example by ID. +// Returns domain.ErrExampleNotFound if not found. +func (r *ExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + e, ok := r.examples[id] + if !ok { + return nil, domain.ErrExampleNotFound + } + // Return a copy to prevent external mutation + copy := *e + return ©, nil +} + +// Create stores a new example. +func (r *ExampleRepository) Create(ctx context.Context, example *domain.Example) error { + r.mu.Lock() + defer r.mu.Unlock() + + // Store a copy to prevent external mutation + copy := *example + r.examples[example.ID] = © + return nil +} + +// Update modifies an existing example. +// Returns domain.ErrExampleNotFound if not found. +func (r *ExampleRepository) Update(ctx context.Context, example *domain.Example) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.examples[example.ID]; !ok { + return domain.ErrExampleNotFound + } + // Store a copy to prevent external mutation + copy := *example + r.examples[example.ID] = © + return nil +} + +// Delete removes an example by ID. +// Returns domain.ErrExampleNotFound if not found. +func (r *ExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.examples[id]; !ok { + return domain.ErrExampleNotFound + } + delete(r.examples, id) + return nil +} + +// ExistsByName checks if an example with the given name exists. +func (r *ExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, e := range r.examples { + if e.Name == name { + return true, nil + } + } + return false, nil +} diff --git a/services/auth-api/internal/api/handlers/example.go b/services/auth-api/internal/api/handlers/example.go new file mode 100644 index 0000000..b48e6bd --- /dev/null +++ b/services/auth-api/internal/api/handlers/example.go @@ -0,0 +1,170 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "git.threesix.ai/jordan/slack-verify-1770279078/pkg/app" + "git.threesix.ai/jordan/slack-verify-1770279078/pkg/httperror" + "git.threesix.ai/jordan/slack-verify-1770279078/pkg/httpresponse" + "git.threesix.ai/jordan/slack-verify-1770279078/pkg/logging" + "git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api/internal/domain" + "git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api/internal/service" +) + +// Example handles HTTP requests for example resources. +type Example struct { + svc *service.ExampleService + logger *logging.Logger +} + +// NewExample creates a new Example handler with injected dependencies. +func NewExample(svc *service.ExampleService, logger *logging.Logger) *Example { + return &Example{ + svc: svc, + logger: logger.WithComponent("ExampleHandler"), + } +} + +// CreateRequest is the request body for creating an example. +type CreateRequest struct { + Name string `json:"name" validate:"required,min=1,max=100"` + Description string `json:"description" validate:"max=500"` +} + +// UpdateRequest is the request body for updating an example. +type UpdateRequest struct { + Name string `json:"name" validate:"required,min=1,max=100"` + Description string `json:"description" validate:"max=500"` +} + +// ExampleResponse is the response for an example resource. +type ExampleResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// toResponse converts a domain example to an API response. +func toResponse(e *domain.Example) ExampleResponse { + return ExampleResponse{ + ID: e.ID.String(), + Name: e.Name, + Description: e.Description, + CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: e.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } +} + +// List returns all examples. +func (h *Example) List(w http.ResponseWriter, r *http.Request) error { + examples, err := h.svc.List(r.Context()) + if err != nil { + return err + } + + result := make([]ExampleResponse, len(examples)) + for i, e := range examples { + result[i] = toResponse(&e) + } + + httpresponse.OK(w, r, result) + return nil +} + +// Get returns an example by ID. +func (h *Example) Get(w http.ResponseWriter, r *http.Request) error { + id := chi.URLParam(r, "id") + + // Validate UUID format + if _, err := uuid.Parse(id); err != nil { + return httperror.BadRequest("invalid id format") + } + + example, err := h.svc.Get(r.Context(), domain.ExampleID(id)) + if err != nil { + return mapDomainError(err) + } + + httpresponse.OK(w, r, toResponse(example)) + return nil +} + +// Create creates a new example. +func (h *Example) Create(w http.ResponseWriter, r *http.Request) error { + var req CreateRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + example, err := h.svc.Create(r.Context(), service.CreateInput{ + Name: req.Name, + Description: req.Description, + }) + if err != nil { + return mapDomainError(err) + } + + httpresponse.Created(w, r, toResponse(example)) + return nil +} + +// Update updates an existing example. +func (h *Example) Update(w http.ResponseWriter, r *http.Request) error { + id := chi.URLParam(r, "id") + + if _, err := uuid.Parse(id); err != nil { + return httperror.BadRequest("invalid id format") + } + + var req UpdateRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + example, err := h.svc.Update(r.Context(), domain.ExampleID(id), service.UpdateInput{ + Name: req.Name, + Description: req.Description, + }) + if err != nil { + return mapDomainError(err) + } + + httpresponse.OK(w, r, toResponse(example)) + return nil +} + +// Delete removes an example by ID. +func (h *Example) Delete(w http.ResponseWriter, r *http.Request) error { + id := chi.URLParam(r, "id") + + if _, err := uuid.Parse(id); err != nil { + return httperror.BadRequest("invalid id format") + } + + if err := h.svc.Delete(r.Context(), domain.ExampleID(id)); err != nil { + return mapDomainError(err) + } + + httpresponse.NoContent(w) + return nil +} + +// mapDomainError converts domain errors to HTTP errors. +func mapDomainError(err error) error { + switch { + case errors.Is(err, domain.ErrExampleNotFound): + return httperror.NotFound("example not found") + case errors.Is(err, domain.ErrDuplicateExample): + return httperror.Conflict("example with this name already exists") + case errors.Is(err, domain.ErrInvalidExampleName): + return httperror.BadRequest("invalid example name") + default: + return err + } +} diff --git a/services/auth-api/internal/api/handlers/example_test.go b/services/auth-api/internal/api/handlers/example_test.go new file mode 100644 index 0000000..eeb2f98 --- /dev/null +++ b/services/auth-api/internal/api/handlers/example_test.go @@ -0,0 +1,402 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/go-chi/chi/v5" + + "git.threesix.ai/jordan/slack-verify-1770279078/pkg/logging" + "git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api/internal/domain" + "git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api/internal/port" + "git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api/internal/service" +) + +// mockExampleRepository implements port.ExampleRepository for testing. +type mockExampleRepository struct { + mu sync.RWMutex + examples map[domain.ExampleID]*domain.Example +} + +var _ port.ExampleRepository = (*mockExampleRepository)(nil) + +func newMockExampleRepository() *mockExampleRepository { + return &mockExampleRepository{ + examples: make(map[domain.ExampleID]*domain.Example), + } +} + +func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make([]domain.Example, 0, len(m.examples)) + for _, e := range m.examples { + result = append(result, *e) + } + return result, nil +} + +func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + e, ok := m.examples[id] + if !ok { + return nil, domain.ErrExampleNotFound + } + copy := *e + return ©, nil +} + +func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error { + m.mu.Lock() + defer m.mu.Unlock() + + copy := *example + m.examples[example.ID] = © + return nil +} + +func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.examples[example.ID]; !ok { + return domain.ErrExampleNotFound + } + copy := *example + m.examples[example.ID] = © + return nil +} + +func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.examples[id]; !ok { + return domain.ErrExampleNotFound + } + delete(m.examples, id) + return nil +} + +func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + for _, e := range m.examples { + if e.Name == name { + return true, nil + } + } + return false, nil +} + +func newTestHandler() (*Example, *mockExampleRepository) { + repo := newMockExampleRepository() + svc := service.NewExampleService(repo, logging.Nop()) + handler := NewExample(svc, logging.Nop()) + return handler, repo +} + +func TestExample_List(t *testing.T) { + handler, repo := newTestHandler() + + // Seed data + ex, _ := domain.NewExample("test-id-1", "Test Example", "Description") + _ = repo.Create(context.Background(), ex) + + r := chi.NewRouter() + r.Get("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) { + if err := handler.List(w, r); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/examples", 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"] + if !ok { + t.Fatal("expected 'data' field in response") + } + + items, ok := data.([]any) + if !ok { + t.Fatal("expected 'data' to be an array") + } + + if len(items) != 1 { + t.Errorf("expected 1 item, got %d", len(items)) + } +} + +func TestExample_Get(t *testing.T) { + handler, repo := newTestHandler() + + // Seed data + ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Test Example", "Description") + _ = repo.Create(context.Background(), ex) + + tests := []struct { + name string + id string + wantStatus int + }{ + { + name: "valid uuid - found", + id: "550e8400-e29b-41d4-a716-446655440000", + wantStatus: http.StatusOK, + }, + { + name: "valid uuid - not found", + id: "550e8400-e29b-41d4-a716-446655440001", + wantStatus: http.StatusNotFound, + }, + { + name: "invalid uuid", + id: "not-a-uuid", + wantStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := chi.NewRouter() + r.Get("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) { + if err := handler.Get(w, r); err != nil { + // Map error to status for testing + switch tt.wantStatus { + case http.StatusNotFound: + w.WriteHeader(http.StatusNotFound) + case http.StatusBadRequest: + w.WriteHeader(http.StatusBadRequest) + default: + w.WriteHeader(http.StatusInternalServerError) + } + return + } + }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/examples/"+tt.id, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code) + } + }) + } +} + +func TestExample_Create(t *testing.T) { + handler, repo := newTestHandler() + + // Seed existing data for duplicate test + ex, _ := domain.NewExample("existing-id", "Existing Name", "") + _ = repo.Create(context.Background(), ex) + + tests := []struct { + name string + body any + wantStatus int + }{ + { + name: "valid request", + body: CreateRequest{ + Name: "New Example", + Description: "A test description", + }, + wantStatus: http.StatusCreated, + }, + { + name: "empty body", + body: nil, + wantStatus: http.StatusBadRequest, + }, + { + name: "duplicate name", + body: CreateRequest{ + Name: "Existing Name", + Description: "Conflict", + }, + wantStatus: http.StatusConflict, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := chi.NewRouter() + r.Post("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) { + if err := handler.Create(w, r); err != nil { + switch tt.wantStatus { + case http.StatusBadRequest: + w.WriteHeader(http.StatusBadRequest) + case http.StatusConflict: + w.WriteHeader(http.StatusConflict) + default: + w.WriteHeader(http.StatusInternalServerError) + } + return + } + }) + + 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, "/api/v1/examples", 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", tt.wantStatus, w.Code) + } + }) + } +} + +func TestExample_Delete(t *testing.T) { + handler, repo := newTestHandler() + + // Seed data + ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "To Delete", "") + _ = repo.Create(context.Background(), ex) + + tests := []struct { + name string + id string + wantStatus int + }{ + { + name: "existing example", + id: "550e8400-e29b-41d4-a716-446655440000", + wantStatus: http.StatusNoContent, + }, + { + name: "non-existent example", + id: "550e8400-e29b-41d4-a716-446655440001", + wantStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := chi.NewRouter() + r.Delete("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) { + if err := handler.Delete(w, r); err != nil { + if tt.wantStatus == http.StatusNotFound { + w.WriteHeader(http.StatusNotFound) + } else { + w.WriteHeader(http.StatusBadRequest) + } + return + } + }) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/examples/"+tt.id, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code) + } + }) + } +} + +func TestExample_Update(t *testing.T) { + handler, repo := newTestHandler() + + // Seed data + ex1, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Example 1", "") + _ = repo.Create(context.Background(), ex1) + ex2, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440001", "Example 2", "") + _ = repo.Create(context.Background(), ex2) + + tests := []struct { + name string + id string + body UpdateRequest + wantStatus int + }{ + { + name: "valid update", + id: "550e8400-e29b-41d4-a716-446655440000", + body: UpdateRequest{ + Name: "Updated Name", + Description: "Updated", + }, + wantStatus: http.StatusOK, + }, + { + name: "name conflict", + id: "550e8400-e29b-41d4-a716-446655440000", + body: UpdateRequest{ + Name: "Example 2", + Description: "Conflict", + }, + wantStatus: http.StatusConflict, + }, + { + name: "not found", + id: "550e8400-e29b-41d4-a716-446655440099", + body: UpdateRequest{ + Name: "Whatever", + Description: "", + }, + wantStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := chi.NewRouter() + r.Put("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) { + if err := handler.Update(w, r); err != nil { + switch tt.wantStatus { + case http.StatusNotFound: + w.WriteHeader(http.StatusNotFound) + case http.StatusConflict: + w.WriteHeader(http.StatusConflict) + default: + w.WriteHeader(http.StatusBadRequest) + } + return + } + }) + + body, _ := json.Marshal(tt.body) + req := httptest.NewRequest(http.MethodPut, "/api/v1/examples/"+tt.id, 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", tt.wantStatus, w.Code) + } + }) + } +} diff --git a/services/auth-api/internal/api/handlers/health.go b/services/auth-api/internal/api/handlers/health.go new file mode 100644 index 0000000..1872c0a --- /dev/null +++ b/services/auth-api/internal/api/handlers/health.go @@ -0,0 +1,26 @@ +package handlers + +import ( + "net/http" + + "git.threesix.ai/jordan/slack-verify-1770279078/pkg/httpresponse" + "git.threesix.ai/jordan/slack-verify-1770279078/pkg/logging" +) + +// Health handles health check endpoints. +type Health struct { + logger *logging.Logger +} + +// NewHealth creates a new Health handler. +func NewHealth(logger *logging.Logger) *Health { + return &Health{logger: logger} +} + +// Check returns the service health status. +func (h *Health) Check(w http.ResponseWriter, r *http.Request) { + httpresponse.OK(w, r, map[string]string{ + "service": "auth-api", + "status": "healthy", + }) +} diff --git a/services/auth-api/internal/api/routes.go b/services/auth-api/internal/api/routes.go new file mode 100644 index 0000000..5fced4e --- /dev/null +++ b/services/auth-api/internal/api/routes.go @@ -0,0 +1,54 @@ +// Package api provides HTTP routing and handlers for the auth-api service. +package api + +import ( + "git.threesix.ai/jordan/slack-verify-1770279078/pkg/app" + "git.threesix.ai/jordan/slack-verify-1770279078/pkg/auth" + "git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api/internal/api/handlers" + "git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api/internal/config" + "git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api/internal/service" +) + +// RegisterRoutes registers all HTTP routes for the service. +// Routes are mounted under /api/auth-api to match the ingress path routing. +// This allows the monorepo to expose multiple services under a single domain: +// - https://domain/api/auth-api/health +// - https://domain/api/auth-api/examples +func RegisterRoutes(application *app.App, exampleService *service.ExampleService) { + logger := application.Logger() + cfg := config.Load() + + // Initialize handlers with injected services + healthHandler := handlers.NewHealth(logger) + exampleHandler := handlers.NewExample(exampleService, logger) + + // Build and mount OpenAPI spec + spec := NewServiceSpec() + application.EnableDocs(spec) + + // Register API routes under /api/{service-name} to match ingress path routing. + // The ingress routes /api/auth-api/* to this service. + application.Route("/api/auth-api", func(r app.Router) { + r.Get("/health", healthHandler.Check) + + // Public routes (no auth required) + r.Get("/examples", app.Wrap(exampleHandler.List)) + r.Get("/examples/{id}", app.Wrap(exampleHandler.Get)) + + // Protected routes (auth required when enabled) + r.Group(func(r app.Router) { + if cfg.AuthEnabled { + r.Use(auth.Middleware(auth.MiddlewareConfig{ + Validator: auth.NewJWTValidator(auth.JWTConfig{ + Secret: []byte(cfg.JWTSecret), + Issuer: "slack-verify-1770279078", + }), + })) + } + + r.Post("/examples", app.Wrap(exampleHandler.Create)) + r.Put("/examples/{id}", app.Wrap(exampleHandler.Update)) + r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete)) + }) + }) +} diff --git a/services/auth-api/internal/api/spec.go b/services/auth-api/internal/api/spec.go new file mode 100644 index 0000000..30f6c06 --- /dev/null +++ b/services/auth-api/internal/api/spec.go @@ -0,0 +1,112 @@ +package api + +import "git.threesix.ai/jordan/slack-verify-1770279078/pkg/openapi" + +// NewServiceSpec builds the OpenAPI specification for the auth-api service. +func NewServiceSpec() *openapi.OpenAPISpec { + spec := openapi.NewOpenAPISpec("auth-api API", "1.0.0"). + WithDescription("REST API for the auth-api service"). + WithBearerSecurity("bearer", "JWT authentication token"). + WithTag("Health", "Service health endpoints"). + WithTag("Examples", "Example CRUD endpoints") + + // Define reusable schemas + spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{ + "id": openapi.UUID().WithDescription("Unique identifier"), + "name": openapi.String().WithDescription("Name of the example").WithExample("My Example"), + "description": openapi.String().WithDescription("Optional description").WithExample("A description"), + "created_at": openapi.DateTime().WithDescription("Creation timestamp"), + "updated_at": openapi.DateTime().WithDescription("Last update timestamp"), + }, "id", "name")) + + spec.WithSchema("CreateExampleRequest", openapi.Object(map[string]openapi.Schema{ + "name": openapi.StringWithMinMax(1, 100).WithDescription("Name of the example"), + "description": openapi.StringWithMinMax(0, 500).WithDescription("Optional description"), + }, "name")) + + spec.WithSchema("UpdateExampleRequest", openapi.Object(map[string]openapi.Schema{ + "name": openapi.StringWithMinMax(1, 100).WithDescription("Updated name"), + "description": openapi.StringWithMinMax(0, 500).WithDescription("Updated description"), + })) + + // Health + spec.AddPath("/api/auth-api/health", "get", map[string]any{ + "summary": "Health check", + "tags": []string{"Health"}, + "responses": map[string]any{ + "200": openapi.OpResponse("Service is healthy", openapi.Object(map[string]openapi.Schema{ + "service": openapi.String(), + "status": openapi.String(), + })), + }, + }) + + // List examples + spec.AddPath("/api/auth-api/examples", "get", map[string]any{ + "summary": "List examples", + "description": "Returns a paginated list of examples.", + "tags": []string{"Examples"}, + "parameters": []any{openapi.PageParam(), openapi.PerPageParam()}, + "responses": map[string]any{ + "200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.RefArray("Example"))), + }, + }) + + // Get example + spec.AddPath("/api/auth-api/examples/{id}", "get", map[string]any{ + "summary": "Get example by ID", + "tags": []string{"Examples"}, + "parameters": []any{openapi.IDParam()}, + "responses": map[string]any{ + "200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Example"))), + "404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()), + }, + }) + + // Create example + spec.AddPath("/api/auth-api/examples", "post", map[string]any{ + "summary": "Create example", + "description": "Creates a new example. Requires authentication.", + "tags": []string{"Examples"}, + "security": []map[string][]string{{"bearer": {}}}, + "requestBody": openapi.RequestBody(openapi.Ref("CreateExampleRequest"), true), + "responses": map[string]any{ + "201": openapi.OpResponse("Created", openapi.ResponseSchema(openapi.Ref("Example"))), + "400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()), + "401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()), + "422": openapi.OpResponse("Validation error", openapi.ErrorResponseSchema()), + }, + }) + + // Update example + spec.AddPath("/api/auth-api/examples/{id}", "put", map[string]any{ + "summary": "Update example", + "description": "Updates an existing example. Requires authentication.", + "tags": []string{"Examples"}, + "security": []map[string][]string{{"bearer": {}}}, + "parameters": []any{openapi.IDParam()}, + "requestBody": openapi.RequestBody(openapi.Ref("UpdateExampleRequest"), true), + "responses": map[string]any{ + "200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("Example"))), + "400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()), + "401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()), + "404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()), + }, + }) + + // Delete example + spec.AddPath("/api/auth-api/examples/{id}", "delete", map[string]any{ + "summary": "Delete example", + "description": "Deletes an example by ID. Requires authentication.", + "tags": []string{"Examples"}, + "security": []map[string][]string{{"bearer": {}}}, + "parameters": []any{openapi.IDParam()}, + "responses": map[string]any{ + "204": openapi.OpResponseNoContent(), + "401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()), + "404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()), + }, + }) + + return spec +} diff --git a/services/auth-api/internal/config/config.go b/services/auth-api/internal/config/config.go new file mode 100644 index 0000000..773bfb5 --- /dev/null +++ b/services/auth-api/internal/config/config.go @@ -0,0 +1,34 @@ +// Package config provides service-specific configuration. +package config + +import ( + "os" + "strings" + + "git.threesix.ai/jordan/slack-verify-1770279078/pkg/config" +) + +// Config extends the base config with auth-api-specific settings. +type Config struct { + config.AppConfig + Server config.ServerConfig + Database config.DatabaseConfig + Logging config.LoggingConfig + + // Auth + AuthEnabled bool + JWTSecret string +} + +// Load reads configuration from environment variables. +func Load() *Config { + return &Config{ + AppConfig: config.ReadAppConfig(), + Server: config.ReadServerConfig(), + Database: config.ReadDatabaseConfig(), + Logging: config.ReadLoggingConfig(), + + AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"), + JWTSecret: os.Getenv("JWT_SECRET"), + } +} diff --git a/services/auth-api/internal/domain/errors.go b/services/auth-api/internal/domain/errors.go new file mode 100644 index 0000000..d4ffe10 --- /dev/null +++ b/services/auth-api/internal/domain/errors.go @@ -0,0 +1,21 @@ +// Package domain contains pure domain models with no external dependencies. +// These types represent the core business concepts of the service. +package domain + +import "errors" + +// Domain errors - these are business-level errors that should be translated +// to appropriate HTTP status codes by the handler layer. +var ( + // ErrNotFound indicates a requested resource does not exist. + ErrNotFound = errors.New("not found") + + // ErrExampleNotFound indicates the requested example does not exist. + ErrExampleNotFound = errors.New("example not found") + + // ErrDuplicateExample indicates an example with the same name already exists. + ErrDuplicateExample = errors.New("example with this name already exists") + + // ErrInvalidExampleName indicates the example name is invalid. + ErrInvalidExampleName = errors.New("invalid example name") +) diff --git a/services/auth-api/internal/domain/example.go b/services/auth-api/internal/domain/example.go new file mode 100644 index 0000000..4ee48e9 --- /dev/null +++ b/services/auth-api/internal/domain/example.go @@ -0,0 +1,89 @@ +package domain + +import ( + "time" + "unicode/utf8" +) + +// ExampleID is a strongly-typed identifier for examples. +type ExampleID string + +// String returns the string representation of the ID. +func (id ExampleID) String() string { + return string(id) +} + +// IsZero returns true if the ID is empty. +func (id ExampleID) IsZero() bool { + return id == "" +} + +// Example name constraints. +const ( + MinExampleNameLen = 1 + MaxExampleNameLen = 100 + MaxDescriptionLen = 500 +) + +// Example represents an example domain entity. +// This is a pure domain model with no external dependencies. +type Example struct { + ID ExampleID + Name string + Description string + CreatedAt time.Time + UpdatedAt time.Time +} + +// NewExample creates a new Example with validation. +// Returns ErrInvalidExampleName if the name is invalid. +func NewExample(id ExampleID, name, description string) (*Example, error) { + if err := validateExampleName(name); err != nil { + return nil, err + } + if err := validateDescription(description); err != nil { + return nil, err + } + + now := time.Now().UTC() + return &Example{ + ID: id, + Name: name, + Description: description, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +// Update modifies the example's mutable fields with validation. +// Returns ErrInvalidExampleName if the name is invalid. +func (e *Example) Update(name, description string) error { + if err := validateExampleName(name); err != nil { + return err + } + if err := validateDescription(description); err != nil { + return err + } + + e.Name = name + e.Description = description + e.UpdatedAt = time.Now().UTC() + return nil +} + +// validateExampleName validates an example name. +func validateExampleName(name string) error { + length := utf8.RuneCountInString(name) + if length < MinExampleNameLen || length > MaxExampleNameLen { + return ErrInvalidExampleName + } + return nil +} + +// validateDescription validates a description. +func validateDescription(desc string) error { + if utf8.RuneCountInString(desc) > MaxDescriptionLen { + return ErrInvalidExampleName + } + return nil +} diff --git a/services/auth-api/internal/port/example.go b/services/auth-api/internal/port/example.go new file mode 100644 index 0000000..4ff0083 --- /dev/null +++ b/services/auth-api/internal/port/example.go @@ -0,0 +1,37 @@ +// Package port defines interfaces (ports) for external dependencies. +// These interfaces define the contracts between the application core and +// infrastructure adapters, enabling testability and flexibility. +package port + +import ( + "context" + + "git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api/internal/domain" +) + +// ExampleRepository defines the interface for example persistence operations. +// Implementations may use databases, in-memory storage, or external services. +type ExampleRepository interface { + // List returns all examples. + List(ctx context.Context) ([]domain.Example, error) + + // Get returns an example by ID. + // Returns domain.ErrExampleNotFound if not found. + Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) + + // Create stores a new example. + // The example must have a valid ID set. + Create(ctx context.Context, example *domain.Example) error + + // Update modifies an existing example. + // Returns domain.ErrExampleNotFound if not found. + Update(ctx context.Context, example *domain.Example) error + + // Delete removes an example by ID. + // Returns domain.ErrExampleNotFound if not found. + Delete(ctx context.Context, id domain.ExampleID) error + + // ExistsByName checks if an example with the given name exists. + // Used for duplicate detection. + ExistsByName(ctx context.Context, name string) (bool, error) +} diff --git a/services/auth-api/internal/service/example.go b/services/auth-api/internal/service/example.go new file mode 100644 index 0000000..7acefbd --- /dev/null +++ b/services/auth-api/internal/service/example.go @@ -0,0 +1,137 @@ +// Package service provides business logic / use cases for the application. +// Services orchestrate domain operations using port interfaces. +package service + +import ( + "context" + "errors" + + "github.com/google/uuid" + + "git.threesix.ai/jordan/slack-verify-1770279078/pkg/logging" + "git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api/internal/domain" + "git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api/internal/port" +) + +// ExampleService handles example-related business logic. +type ExampleService struct { + repo port.ExampleRepository + logger *logging.Logger +} + +// NewExampleService creates a new example service. +func NewExampleService(repo port.ExampleRepository, logger *logging.Logger) *ExampleService { + return &ExampleService{ + repo: repo, + logger: logger.WithService("ExampleService"), + } +} + +// List returns all examples. +func (s *ExampleService) List(ctx context.Context) ([]domain.Example, error) { + return s.repo.List(ctx) +} + +// Get returns an example by ID. +// Returns domain.ErrExampleNotFound if not found. +func (s *ExampleService) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) { + return s.repo.Get(ctx, id) +} + +// CreateInput contains the data needed to create an example. +type CreateInput struct { + Name string + Description string +} + +// Create creates a new example with duplicate detection. +// Returns domain.ErrDuplicateExample if name already exists. +// Returns domain.ErrInvalidExampleName if name is invalid. +func (s *ExampleService) Create(ctx context.Context, input CreateInput) (*domain.Example, error) { + // Check for duplicates + exists, err := s.repo.ExistsByName(ctx, input.Name) + if err != nil { + return nil, err + } + if exists { + return nil, domain.ErrDuplicateExample + } + + // Generate new ID + id := domain.ExampleID(uuid.New().String()) + + // Create domain entity (validates name) + example, err := domain.NewExample(id, input.Name, input.Description) + if err != nil { + return nil, err + } + + // Persist + if err := s.repo.Create(ctx, example); err != nil { + return nil, err + } + + s.logger.Info("example created", "id", id, "name", input.Name) + return example, nil +} + +// UpdateInput contains the data needed to update an example. +type UpdateInput struct { + Name string + Description string +} + +// Update modifies an existing example. +// Returns domain.ErrExampleNotFound if not found. +// Returns domain.ErrDuplicateExample if new name conflicts with another example. +// Returns domain.ErrInvalidExampleName if name is invalid. +func (s *ExampleService) Update(ctx context.Context, id domain.ExampleID, input UpdateInput) (*domain.Example, error) { + // Fetch existing + example, err := s.repo.Get(ctx, id) + if err != nil { + return nil, err + } + + // Check for name conflicts (only if name changed) + if example.Name != input.Name { + exists, err := s.repo.ExistsByName(ctx, input.Name) + if err != nil { + return nil, err + } + if exists { + return nil, domain.ErrDuplicateExample + } + } + + // Update domain entity (validates name) + if err := example.Update(input.Name, input.Description); err != nil { + return nil, err + } + + // Persist + if err := s.repo.Update(ctx, example); err != nil { + return nil, err + } + + s.logger.Info("example updated", "id", id, "name", input.Name) + return example, nil +} + +// Delete removes an example by ID. +// Returns domain.ErrExampleNotFound if not found. +func (s *ExampleService) Delete(ctx context.Context, id domain.ExampleID) error { + // Verify exists before delete + if _, err := s.repo.Get(ctx, id); err != nil { + if errors.Is(err, domain.ErrExampleNotFound) { + return domain.ErrExampleNotFound + } + return err + } + + if err := s.repo.Delete(ctx, id); err != nil { + return err + } + + s.logger.Info("example deleted", "id", id) + return nil +} diff --git a/services/auth-api/internal/service/example_test.go b/services/auth-api/internal/service/example_test.go new file mode 100644 index 0000000..6c0fc3e --- /dev/null +++ b/services/auth-api/internal/service/example_test.go @@ -0,0 +1,282 @@ +package service + +import ( + "context" + "sync" + "testing" + + "git.threesix.ai/jordan/slack-verify-1770279078/pkg/logging" + "git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api/internal/domain" + "git.threesix.ai/jordan/slack-verify-1770279078/services/auth-api/internal/port" +) + +// mockExampleRepository implements port.ExampleRepository for testing. +type mockExampleRepository struct { + mu sync.RWMutex + examples map[domain.ExampleID]*domain.Example +} + +var _ port.ExampleRepository = (*mockExampleRepository)(nil) + +func newMockExampleRepository() *mockExampleRepository { + return &mockExampleRepository{ + examples: make(map[domain.ExampleID]*domain.Example), + } +} + +func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make([]domain.Example, 0, len(m.examples)) + for _, e := range m.examples { + result = append(result, *e) + } + return result, nil +} + +func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + e, ok := m.examples[id] + if !ok { + return nil, domain.ErrExampleNotFound + } + // Return a copy to avoid mutation + copy := *e + return ©, nil +} + +func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Store a copy + copy := *example + m.examples[example.ID] = © + return nil +} + +func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.examples[example.ID]; !ok { + return domain.ErrExampleNotFound + } + // Store a copy + copy := *example + m.examples[example.ID] = © + return nil +} + +func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.examples[id]; !ok { + return domain.ErrExampleNotFound + } + delete(m.examples, id) + return nil +} + +func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + for _, e := range m.examples { + if e.Name == name { + return true, nil + } + } + return false, nil +} + +func TestExampleService_Create(t *testing.T) { + repo := newMockExampleRepository() + svc := NewExampleService(repo, logging.Nop()) + + t.Run("creates example successfully", func(t *testing.T) { + example, err := svc.Create(context.Background(), CreateInput{ + Name: "Test Example", + Description: "A test description", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if example.Name != "Test Example" { + t.Errorf("expected name 'Test Example', got '%s'", example.Name) + } + if example.ID.IsZero() { + t.Error("expected non-empty ID") + } + }) + + t.Run("rejects duplicate name", func(t *testing.T) { + _, err := svc.Create(context.Background(), CreateInput{ + Name: "Test Example", + Description: "Another description", + }) + if err != domain.ErrDuplicateExample { + t.Errorf("expected ErrDuplicateExample, got %v", err) + } + }) + + t.Run("rejects empty name", func(t *testing.T) { + _, err := svc.Create(context.Background(), CreateInput{ + Name: "", + Description: "Description", + }) + if err != domain.ErrInvalidExampleName { + t.Errorf("expected ErrInvalidExampleName, got %v", err) + } + }) +} + +func TestExampleService_Get(t *testing.T) { + repo := newMockExampleRepository() + svc := NewExampleService(repo, logging.Nop()) + + // Create an example first + created, _ := svc.Create(context.Background(), CreateInput{ + Name: "Get Test", + Description: "Description", + }) + + t.Run("returns existing example", func(t *testing.T) { + example, err := svc.Get(context.Background(), created.ID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if example.Name != "Get Test" { + t.Errorf("expected name 'Get Test', got '%s'", example.Name) + } + }) + + t.Run("returns not found for missing example", func(t *testing.T) { + _, err := svc.Get(context.Background(), "nonexistent-id") + if err != domain.ErrExampleNotFound { + t.Errorf("expected ErrExampleNotFound, got %v", err) + } + }) +} + +func TestExampleService_Update(t *testing.T) { + repo := newMockExampleRepository() + svc := NewExampleService(repo, logging.Nop()) + + // Create examples + example1, _ := svc.Create(context.Background(), CreateInput{ + Name: "Update Test 1", + Description: "Original", + }) + _, _ = svc.Create(context.Background(), CreateInput{ + Name: "Update Test 2", + Description: "Other", + }) + + t.Run("updates example successfully", func(t *testing.T) { + updated, err := svc.Update(context.Background(), example1.ID, UpdateInput{ + Name: "Updated Name", + Description: "Updated description", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if updated.Name != "Updated Name" { + t.Errorf("expected name 'Updated Name', got '%s'", updated.Name) + } + }) + + t.Run("allows same name on same example", func(t *testing.T) { + _, err := svc.Update(context.Background(), example1.ID, UpdateInput{ + Name: "Updated Name", + Description: "Same name", + }) + if err != nil { + t.Errorf("unexpected error updating with same name: %v", err) + } + }) + + t.Run("rejects name conflict", func(t *testing.T) { + _, err := svc.Update(context.Background(), example1.ID, UpdateInput{ + Name: "Update Test 2", + Description: "Conflict", + }) + if err != domain.ErrDuplicateExample { + t.Errorf("expected ErrDuplicateExample, got %v", err) + } + }) + + t.Run("returns not found for missing example", func(t *testing.T) { + _, err := svc.Update(context.Background(), "nonexistent-id", UpdateInput{ + Name: "Anything", + Description: "", + }) + if err != domain.ErrExampleNotFound { + t.Errorf("expected ErrExampleNotFound, got %v", err) + } + }) +} + +func TestExampleService_Delete(t *testing.T) { + repo := newMockExampleRepository() + svc := NewExampleService(repo, logging.Nop()) + + // Create an example first + created, _ := svc.Create(context.Background(), CreateInput{ + Name: "Delete Test", + Description: "To be deleted", + }) + + t.Run("deletes example successfully", func(t *testing.T) { + err := svc.Delete(context.Background(), created.ID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify deleted + _, err = svc.Get(context.Background(), created.ID) + if err != domain.ErrExampleNotFound { + t.Errorf("expected ErrExampleNotFound after delete, got %v", err) + } + }) + + t.Run("returns not found for missing example", func(t *testing.T) { + err := svc.Delete(context.Background(), "nonexistent-id") + if err != domain.ErrExampleNotFound { + t.Errorf("expected ErrExampleNotFound, got %v", err) + } + }) +} + +func TestExampleService_List(t *testing.T) { + repo := newMockExampleRepository() + svc := NewExampleService(repo, logging.Nop()) + + t.Run("returns empty list initially", func(t *testing.T) { + examples, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(examples) != 0 { + t.Errorf("expected 0 examples, got %d", len(examples)) + } + }) + + // Create some examples + _, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 1", Description: ""}) + _, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 2", Description: ""}) + + t.Run("returns all examples", func(t *testing.T) { + examples, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(examples) != 2 { + t.Errorf("expected 2 examples, got %d", len(examples)) + } + }) +} diff --git a/services/auth-api/migrations/.gitkeep b/services/auth-api/migrations/.gitkeep new file mode 100644 index 0000000..e69de29