This commit is contained in:
2026-08-22 02:59:16 +02:00
commit 6a5bb1d699
100 changed files with 17409 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
package gifts
import (
"encoding/json"
"errors"
"net/http"
"strings"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", h.Health)
mux.HandleFunc("GET /api/gifts/{slug}", h.GetGift)
return mux
}
func (h *Handler) Health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *Handler) GetGift(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(r.PathValue("slug"))
if !validSlug(slug) {
writeError(w, http.StatusBadRequest, "invalid gift slug")
return
}
gift, err := h.service.GetPublicGift(r.Context(), slug)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "gift not found")
return
}
writeError(w, http.StatusInternalServerError, "could not load gift")
return
}
writeJSON(w, http.StatusOK, gift)
}
func validSlug(slug string) bool {
if len(slug) == 0 || len(slug) > 100 {
return false
}
for index, character := range slug {
if (character >= 'a' && character <= 'z') ||
(character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') ||
(character == '-' && index > 0 && index < len(slug)-1) {
continue
}
return false
}
return true
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+125
View File
@@ -0,0 +1,125 @@
package gifts
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
)
type fakeStore struct {
gift PublicGift
err error
}
func (f fakeStore) GetPublicBySlug(context.Context, string) (PublicGift, error) {
return f.gift, f.err
}
func TestGetGiftReturnsPublicRepresentation(t *testing.T) {
giftID := uuid.MustParse("11111111-1111-4111-8111-111111111111")
itemID := uuid.MustParse("22222222-2222-4222-8222-222222222222")
service := NewService(fakeStore{gift: PublicGift{
ID: giftID,
Slug: "demo",
RecipientName: "Anna",
SenderName: "Alex",
Title: "A little surprise for you",
IntroMessage: "I made something for you.",
RevealMessage: "A beautiful final note.",
Items: []PublicGiftItem{{
ID: itemID,
Type: "text",
Title: "A note",
Text: "Hello",
SortOrder: 1,
}},
}})
request := httptest.NewRequest(http.MethodGet, "/api/gifts/demo", nil)
recorder := httptest.NewRecorder()
NewHandler(service).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
if contentType := recorder.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" {
t.Fatalf("unexpected content type: %q", contentType)
}
var response PublicGift
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.RecipientName != "Anna" || len(response.Items) != 1 {
t.Fatalf("unexpected response: %+v", response)
}
}
func TestGetGiftReturnsNotFound(t *testing.T) {
service := NewService(fakeStore{err: ErrNotFound})
request := httptest.NewRequest(http.MethodGet, "/api/gifts/missing", nil)
recorder := httptest.NewRecorder()
NewHandler(service).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", recorder.Code)
}
if body := recorder.Body.String(); body != "{\"error\":\"gift not found\"}\n" {
t.Fatalf("unexpected error body: %q", body)
}
}
func TestGetGiftHidesStoreErrors(t *testing.T) {
service := NewService(fakeStore{err: errors.New("database connection lost")})
request := httptest.NewRequest(http.MethodGet, "/api/gifts/demo", nil)
recorder := httptest.NewRecorder()
NewHandler(service).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", recorder.Code)
}
if body := recorder.Body.String(); body != "{\"error\":\"could not load gift\"}\n" {
t.Fatalf("unexpected error body: %q", body)
}
}
func TestGetGiftRejectsInvalidSlug(t *testing.T) {
service := NewService(fakeStore{})
request := httptest.NewRequest(http.MethodGet, "/api/gifts/not%20a%20slug", nil)
recorder := httptest.NewRecorder()
NewHandler(service).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", recorder.Code)
}
}
func TestHealthReturnsOK(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/health", nil)
NewHandler(NewService(fakeStore{})).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
if body := recorder.Body.String(); body != "{\"status\":\"ok\"}\n" {
t.Fatalf("unexpected health body: %q", body)
}
}
func TestServiceRejectsEmptySlug(t *testing.T) {
service := NewService(fakeStore{})
_, err := service.GetPublicGift(context.Background(), "")
if err != ErrNotFound {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
+28
View File
@@ -0,0 +1,28 @@
package gifts
import (
"encoding/json"
"github.com/google/uuid"
)
type PublicGift struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
RecipientName string `json:"recipientName"`
SenderName string `json:"senderName"`
Title string `json:"title"`
IntroMessage string `json:"introMessage"`
RevealMessage string `json:"revealMessage"`
Items []PublicGiftItem `json:"items"`
}
type PublicGiftItem struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
Text string `json:"text,omitempty"`
MediaURL string `json:"mediaUrl,omitempty"`
SortOrder int `json:"sortOrder"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
+97
View File
@@ -0,0 +1,97 @@
package gifts
import (
"context"
"database/sql"
"errors"
"fmt"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) GetPublicBySlug(ctx context.Context, slug string) (PublicGift, error) {
var gift PublicGift
err := r.db.QueryRowContext(ctx, `
SELECT id, slug, recipient_name, sender_name, title, intro_message, reveal_message
FROM gifts
WHERE slug = $1 AND status = 'published'
`, slug).Scan(
&gift.ID,
&gift.Slug,
&gift.RecipientName,
&gift.SenderName,
&gift.Title,
&gift.IntroMessage,
&gift.RevealMessage,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return PublicGift{}, ErrNotFound
}
return PublicGift{}, fmt.Errorf("find gift: %w", err)
}
rows, err := r.db.QueryContext(ctx, `
SELECT id, type, title, text, media_url, sort_order, metadata
FROM gift_items
WHERE gift_id = $1
ORDER BY sort_order ASC, id ASC
`, gift.ID)
if err != nil {
return PublicGift{}, fmt.Errorf("find gift items: %w", err)
}
defer rows.Close()
gift.Items = make([]PublicGiftItem, 0)
for rows.Next() {
var (
item PublicGiftItem
title sql.NullString
text sql.NullString
mediaURL sql.NullString
metadata []byte
)
if err := rows.Scan(
&item.ID,
&item.Type,
&title,
&text,
&mediaURL,
&item.SortOrder,
&metadata,
); err != nil {
return PublicGift{}, fmt.Errorf("scan gift item: %w", err)
}
item.Title = title.String
item.Text = text.String
item.MediaURL = mediaURL.String
if len(metadata) == 0 {
item.Metadata = []byte(`{}`)
} else {
item.Metadata = metadata
}
gift.Items = append(gift.Items, item)
}
if err := rows.Err(); err != nil {
return PublicGift{}, fmt.Errorf("iterate gift items: %w", err)
}
return gift, nil
}
// Store is the read contract used by the service. Keeping it small makes the
// HTTP layer straightforward to test without a running database.
type Store interface {
GetPublicBySlug(context.Context, string) (PublicGift, error)
}
var _ Store = (*Repository)(nil)
@@ -0,0 +1,57 @@
package gifts
import (
"context"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"
appdb "github.com/example/sndit/backend/internal/db"
)
func TestRepositoryReadsSQLiteMigrations(t *testing.T) {
ctx := context.Background()
database, err := appdb.New(ctx, "sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite database: %v", err)
}
defer database.Close()
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("find test source file")
}
migrationDirectory := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "migrations", "sqlite")
entries, err := os.ReadDir(migrationDirectory)
if err != nil {
t.Fatalf("read sqlite migrations: %v", err)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
migration, err := os.ReadFile(filepath.Join(migrationDirectory, entry.Name()))
if err != nil {
t.Fatalf("read migration %s: %v", entry.Name(), err)
}
if _, err := database.ExecContext(ctx, string(migration)); err != nil {
t.Fatalf("apply migration %s: %v", entry.Name(), err)
}
}
gift, err := NewRepository(database).GetPublicBySlug(ctx, "demo")
if err != nil {
t.Fatalf("load demo gift: %v", err)
}
if gift.RecipientName != "Anna" || gift.SenderName != "Alex" {
t.Fatalf("unexpected gift: %+v", gift)
}
if len(gift.Items) != 3 || gift.Items[0].Type != "image" || gift.Items[1].SortOrder != 2 {
t.Fatalf("unexpected gift items: %+v", gift.Items)
}
}
+33
View File
@@ -0,0 +1,33 @@
package gifts
import (
"context"
"errors"
"fmt"
)
var ErrNotFound = errors.New("gift not found")
type Service struct {
store Store
}
func NewService(store Store) *Service {
return &Service{store: store}
}
func (s *Service) GetPublicGift(ctx context.Context, slug string) (PublicGift, error) {
if slug == "" {
return PublicGift{}, ErrNotFound
}
gift, err := s.store.GetPublicBySlug(ctx, slug)
if err != nil {
if errors.Is(err, ErrNotFound) {
return PublicGift{}, ErrNotFound
}
return PublicGift{}, fmt.Errorf("get public gift: %w", err)
}
return gift, nil
}