init
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user