initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:14:50 +02:00
commit 1253955070
75 changed files with 13972 additions and 0 deletions

795
api/internal/store/sql.go Normal file
View file

@ -0,0 +1,795 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
_ "modernc.org/sqlite" // registers the "sqlite" driver, CGO-free
"go-places-api/internal/domain"
)
// SQLStore is the SQLite-backed implementation of Store.
type SQLStore struct {
db *sql.DB
}
var _ Store = (*SQLStore)(nil)
// OpenSQL opens the SQLite database at dsn and applies the schema. The dsn is a
// SQLite connection string such as "file:go-places.db?_pragma=foreign_keys(1)".
func OpenSQL(dsn string) (*SQLStore, error) {
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("store: open database: %w", err)
}
// SQLite serialises writers. A single connection avoids "database is
// locked" errors under concurrent uploads without needing a write mutex.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(time.Hour)
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("store: ping database: %w", err)
}
s := &SQLStore{db: db}
if err := s.migrate(context.Background()); err != nil {
db.Close()
return nil, err
}
return s, nil
}
// Close releases the underlying database handle.
func (s *SQLStore) Close() error { return s.db.Close() }
// DB exposes the handle for health checks.
func (s *SQLStore) DB() *sql.DB { return s.db }
const schema = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS places (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
latitude REAL,
longitude REAL,
link_radius_meters REAL NOT NULL DEFAULT 150,
creator_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_places_creator ON places(creator_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_places_geo ON places(latitude, longitude);
CREATE TABLE IF NOT EXISTS images (
id TEXT PRIMARY KEY,
place_id TEXT NOT NULL REFERENCES places(id) ON DELETE CASCADE,
storage_key TEXT NOT NULL,
content_type TEXT NOT NULL,
byte_size INTEGER NOT NULL,
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
caption TEXT NOT NULL DEFAULT '',
latitude REAL,
longitude REAL,
taken_at TEXT,
camera_make TEXT NOT NULL DEFAULT '',
camera_model TEXT NOT NULL DEFAULT '',
analysis_status TEXT NOT NULL DEFAULT 'pending',
analysis_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_images_place ON images(place_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_images_created ON images(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_images_geo ON images(latitude, longitude);
CREATE TABLE IF NOT EXISTS badges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE,
label TEXT NOT NULL,
display TEXT NOT NULL DEFAULT '',
confidence REAL NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT '',
UNIQUE(image_id, label, source)
);
CREATE INDEX IF NOT EXISTS idx_badges_image ON badges(image_id);
CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
place_id TEXT NOT NULL REFERENCES places(id) ON DELETE CASCADE,
content TEXT NOT NULL,
creator_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_comments_place ON comments(place_id, created_at);
-- Evidence that two pictures were taken at the same place. Both directions are
-- stored so a link is discoverable from either picture.
CREATE TABLE IF NOT EXISTS image_links (
image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE,
related_image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE,
distance_meters REAL NOT NULL DEFAULT 0,
reason TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
PRIMARY KEY (image_id, related_image_id)
);
CREATE INDEX IF NOT EXISTS idx_image_links_related ON image_links(related_image_id);
`
func (s *SQLStore) migrate(ctx context.Context) error {
if _, err := s.db.ExecContext(ctx, schema); err != nil {
return fmt.Errorf("store: apply schema: %w", err)
}
return nil
}
// ---------------------------------------------------------------- places ---
func (s *SQLStore) CreatePlace(ctx context.Context, p domain.Place) (domain.Place, error) {
if p.ID == "" {
p.ID = newID()
}
now := time.Now().UTC()
p.CreatedAt = now
// Timestamps are stored as RFC3339 so the schema stays portable to engines
// without a native timestamp type.
_, err := s.db.ExecContext(ctx, `
INSERT INTO places (id, title, description, latitude, longitude,
link_radius_meters, creator_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.ID, p.Title, p.Description,
nullableFloat(p.Coordinate, func(c domain.Coordinate) float64 { return c.Latitude }),
nullableFloat(p.Coordinate, func(c domain.Coordinate) float64 { return c.Longitude }),
p.LinkRadiusMeters, p.Creator.ID,
formatTime(p.CreatedAt), formatTime(now),
)
if err != nil {
return domain.Place{}, wrapWrite(err, "place")
}
return s.GetPlace(ctx, p.ID)
}
func (s *SQLStore) GetPlace(ctx context.Context, id string) (domain.Place, error) {
row := s.db.QueryRowContext(ctx, `
SELECT p.id, p.title, p.description, p.latitude, p.longitude,
p.link_radius_meters, p.created_at,
u.id, u.username, u.email
FROM places p
JOIN users u ON u.id = p.creator_id
WHERE p.id = ?`, id)
p, err := scanPlace(row)
if err != nil {
return domain.Place{}, err
}
images, err := s.ListImages(ctx, ImageFilter{PlaceID: id})
if err != nil {
return domain.Place{}, err
}
comments, err := s.ListComments(ctx, id)
if err != nil {
return domain.Place{}, err
}
p.Images = images
p.Comments = comments
return p, nil
}
func (s *SQLStore) UpdatePlace(ctx context.Context, p domain.Place) (domain.Place, error) {
res, err := s.db.ExecContext(ctx, `
UPDATE places
SET title = ?, description = ?, latitude = ?, longitude = ?,
link_radius_meters = ?, updated_at = ?
WHERE id = ?`,
p.Title, p.Description,
nullableFloat(p.Coordinate, func(c domain.Coordinate) float64 { return c.Latitude }),
nullableFloat(p.Coordinate, func(c domain.Coordinate) float64 { return c.Longitude }),
p.LinkRadiusMeters, formatTime(time.Now().UTC()), p.ID,
)
if err != nil {
return domain.Place{}, wrapWrite(err, "place")
}
if n, _ := res.RowsAffected(); n == 0 {
return domain.Place{}, ErrNotFound
}
return s.GetPlace(ctx, p.ID)
}
func (s *SQLStore) ListPlaces(ctx context.Context, limit, offset int) ([]domain.Place, error) {
query := `
SELECT p.id, p.title, p.description, p.latitude, p.longitude,
p.link_radius_meters, p.created_at,
u.id, u.username, u.email
FROM places p
JOIN users u ON u.id = p.creator_id
ORDER BY p.created_at DESC`
args := []any{}
if limit > 0 {
query += " LIMIT ?"
args = append(args, limit)
if offset > 0 {
query += " OFFSET ?"
args = append(args, offset)
}
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("store: list places: %w", err)
}
defer rows.Close()
var out []domain.Place
for rows.Next() {
p, err := scanPlace(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func (s *SQLStore) DeletePlace(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM places WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("store: delete place: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// ---------------------------------------------------------------- images ---
func (s *SQLStore) CreateImage(ctx context.Context, img domain.Image) (domain.Image, error) {
if img.ID == "" {
img.ID = newID()
}
if img.AnalysisStatus == "" {
img.AnalysisStatus = domain.AnalysisPending
}
if img.CreatedAt.IsZero() {
img.CreatedAt = time.Now().UTC()
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO images (id, place_id, storage_key, content_type, byte_size,
width, height, caption, latitude, longitude, taken_at,
camera_make, camera_model, analysis_status, analysis_error,
created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
img.ID, img.PlaceID, img.StorageKey, img.ContentType, img.ByteSize,
img.Width, img.Height, img.Caption,
nullableFloat(img.Coordinate, func(c domain.Coordinate) float64 { return c.Latitude }),
nullableFloat(img.Coordinate, func(c domain.Coordinate) float64 { return c.Longitude }),
nullableTime(img.TakenAt),
img.CameraMake, img.CameraModel, string(img.AnalysisStatus), img.AnalysisError,
formatTime(img.CreatedAt),
)
if err != nil {
return domain.Image{}, wrapWrite(err, "image")
}
if len(img.Badges) > 0 {
if err := s.ReplaceBadges(ctx, img.ID, img.Badges); err != nil {
return domain.Image{}, err
}
}
return s.GetImage(ctx, img.ID)
}
func (s *SQLStore) GetImage(ctx context.Context, id string) (domain.Image, error) {
row := s.db.QueryRowContext(ctx, imageSelect+` WHERE i.id = ?`, id)
img, err := scanImage(row)
if err != nil {
return domain.Image{}, err
}
badges, err := s.badgesFor(ctx, []string{id})
if err != nil {
return domain.Image{}, err
}
img.Badges = badges[id]
return img, nil
}
func (s *SQLStore) GetImageByStorageKey(ctx context.Context, key string) (domain.Image, error) {
row := s.db.QueryRowContext(ctx, imageSelect+` WHERE i.storage_key = ? LIMIT 1`, key)
img, err := scanImage(row)
if err != nil {
return domain.Image{}, err
}
badges, err := s.badgesFor(ctx, []string{img.ID})
if err != nil {
return domain.Image{}, err
}
img.Badges = badges[img.ID]
return img, nil
}
// Ping verifies the database is reachable. It backs the readiness probe, which
// reports the service as unavailable without restarting it.
func (s *SQLStore) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
// PingContext is an alias for Ping kept for callers that name the method after
// database/sql's own convention.
func (s *SQLStore) PingContext(ctx context.Context) error { return s.db.PingContext(ctx) }
func (s *SQLStore) UpdateImage(ctx context.Context, img domain.Image) (domain.Image, error) {
res, err := s.db.ExecContext(ctx, `
UPDATE images
SET caption = ?, latitude = ?, longitude = ?, taken_at = ?,
camera_make = ?, camera_model = ?,
analysis_status = ?, analysis_error = ?
WHERE id = ?`,
img.Caption,
nullableFloat(img.Coordinate, func(c domain.Coordinate) float64 { return c.Latitude }),
nullableFloat(img.Coordinate, func(c domain.Coordinate) float64 { return c.Longitude }),
nullableTime(img.TakenAt),
img.CameraMake, img.CameraModel,
string(img.AnalysisStatus), img.AnalysisError, img.ID,
)
if err != nil {
return domain.Image{}, wrapWrite(err, "image")
}
if n, _ := res.RowsAffected(); n == 0 {
return domain.Image{}, ErrNotFound
}
return s.GetImage(ctx, img.ID)
}
func (s *SQLStore) ListImages(ctx context.Context, f ImageFilter) ([]domain.Image, error) {
var (
where []string
args []any
)
if f.PlaceID != "" {
where = append(where, "i.place_id = ?")
args = append(args, f.PlaceID)
}
if f.CreatorID != "" {
where = append(where, "p.creator_id = ?")
args = append(args, f.CreatorID)
}
if f.Within != nil {
// Pre-filter with the bounding box; the exact haversine test is applied
// by the service layer afterwards.
b := f.Within.BoundingBox()
where = append(where,
"i.latitude IS NOT NULL", "i.longitude IS NOT NULL",
"i.latitude BETWEEN ? AND ?", "i.longitude BETWEEN ? AND ?")
args = append(args, b.MinLat, b.MaxLat, b.MinLon, b.MaxLon)
}
query := imageSelect
if len(where) > 0 {
query += " WHERE " + strings.Join(where, " AND ")
}
query += " ORDER BY i.created_at DESC"
if f.Limit > 0 {
query += " LIMIT ?"
args = append(args, f.Limit)
if f.Offset > 0 {
query += " OFFSET ?"
args = append(args, f.Offset)
}
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("store: list images: %w", err)
}
defer rows.Close()
var out []domain.Image
var ids []string
for rows.Next() {
img, err := scanImage(rows)
if err != nil {
return nil, err
}
out = append(out, img)
ids = append(ids, img.ID)
}
if err := rows.Err(); err != nil {
return nil, err
}
badges, err := s.badgesFor(ctx, ids)
if err != nil {
return nil, err
}
for i := range out {
out[i].Badges = badges[out[i].ID]
}
return out, nil
}
func (s *SQLStore) ImagesWithin(ctx context.Context, q NearbyQuery) ([]domain.Image, error) {
return s.ListImages(ctx, ImageFilter{Within: &q})
}
func (s *SQLStore) ReplaceBadges(ctx context.Context, imageID string, badges []domain.Badge) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("store: begin badge transaction: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM badges WHERE image_id = ?`, imageID); err != nil {
return fmt.Errorf("store: clear badges: %w", err)
}
for _, b := range badges {
if _, err := tx.ExecContext(ctx, `
INSERT OR REPLACE INTO badges (image_id, label, display, confidence, source)
VALUES (?, ?, ?, ?, ?)`,
imageID, b.Label, b.Display, b.Confidence, b.Source,
); err != nil {
return fmt.Errorf("store: insert badge: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("store: commit badges: %w", err)
}
return nil
}
func (s *SQLStore) SetAnalysisState(ctx context.Context, imageID string, status domain.AnalysisStatus, failure string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE images SET analysis_status = ?, analysis_error = ? WHERE id = ?`,
string(status), failure, imageID)
if err != nil {
return fmt.Errorf("store: set analysis state: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLStore) DeleteImage(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM images WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("store: delete image: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// -------------------------------------------------------------- image links ---
func (s *SQLStore) CreateImageLink(ctx context.Context, imageID, relatedImageID string, distanceMeters float64, reason string) error {
if imageID == relatedImageID {
return fmt.Errorf("%w: a picture cannot be linked to itself", ErrInvalidInput)
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO image_links (image_id, related_image_id, distance_meters, reason, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(image_id, related_image_id) DO UPDATE SET
distance_meters = excluded.distance_meters,
reason = excluded.reason`,
imageID, relatedImageID, distanceMeters, reason, formatTime(time.Now().UTC()))
if err != nil {
return wrapWrite(err, "image link")
}
return nil
}
func (s *SQLStore) ListImageLinks(ctx context.Context, imageID string) ([]domain.Link, error) {
// The stored rows record the direction they were written in, so this query
// flips the orientation for rows where the picture is the related end.
rows, err := s.db.QueryContext(ctx, `
SELECT image_id, related_image_id, distance_meters, reason,
CASE WHEN image_id = ? THEN 0 ELSE 1 END AS direction
FROM image_links
WHERE image_id = ? OR related_image_id = ?
ORDER BY distance_meters ASC, related_image_id ASC`, imageID, imageID, imageID)
if err != nil {
return nil, fmt.Errorf("store: list image links: %w", err)
}
defer rows.Close()
var out []domain.Link
for rows.Next() {
var (
l domain.Link
storedA string
storedB string
direction int
)
if err := rows.Scan(&storedA, &storedB, &l.DistanceMeters, &l.Reason, &direction); err != nil {
return nil, fmt.Errorf("store: scan image link: %w", err)
}
if direction == 0 {
l.ImageID, l.RelatedImageID = storedA, storedB
} else {
l.ImageID, l.RelatedImageID = storedB, storedA
}
l.SamePlace = l.Reason == "same-place"
out = append(out, l)
}
return out, rows.Err()
}
// -------------------------------------------------------------- comments ---
func (s *SQLStore) AddComment(ctx context.Context, c domain.Comment) (domain.Comment, error) {
if c.ID == "" {
c.ID = newID()
}
if c.CreatedAt.IsZero() {
c.CreatedAt = time.Now().UTC()
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO comments (id, place_id, content, creator_id, created_at)
VALUES (?, ?, ?, ?, ?)`,
c.ID, c.PlaceID, c.Content, c.Creator.ID, formatTime(c.CreatedAt))
if err != nil {
return domain.Comment{}, wrapWrite(err, "comment")
}
return c, nil
}
func (s *SQLStore) ListComments(ctx context.Context, placeID string) ([]domain.Comment, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT c.id, c.place_id, c.content, c.created_at,
u.id, u.username, u.email
FROM comments c
JOIN users u ON u.id = c.creator_id
WHERE c.place_id = ?
ORDER BY c.created_at`, placeID)
if err != nil {
return nil, fmt.Errorf("store: list comments: %w", err)
}
defer rows.Close()
var out []domain.Comment
for rows.Next() {
var (
c domain.Comment
created timeText
)
err := rows.Scan(&c.ID, &c.PlaceID, &c.Content, &created, &c.Creator.ID, &c.Creator.Username, &c.Creator.Email)
if err != nil {
return nil, fmt.Errorf("store: scan comment: %w", err)
}
c.CreatedAt = created.Time
out = append(out, c)
}
return out, rows.Err()
}
// ----------------------------------------------------------------- users ---
func (s *SQLStore) UpsertUser(ctx context.Context, u domain.User) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO users (id, username, email, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
username = CASE WHEN excluded.username = '' THEN users.username ELSE excluded.username END,
email = CASE WHEN excluded.email = '' THEN users.email ELSE excluded.email END`,
u.ID, u.Username, u.Email, formatTime(time.Now().UTC()))
if err != nil {
return wrapWrite(err, "user")
}
return nil
}
func (s *SQLStore) GetUser(ctx context.Context, id string) (domain.User, error) {
var u domain.User
err := s.db.QueryRowContext(ctx,
`SELECT id, username, email FROM users WHERE id = ?`, id).
Scan(&u.ID, &u.Username, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
return domain.User{}, ErrNotFound
}
if err != nil {
return domain.User{}, fmt.Errorf("store: get user: %w", err)
}
return u, nil
}
// --------------------------------------------------------------- helpers ---
const imageSelect = `
SELECT i.id, i.place_id, i.storage_key, i.content_type, i.byte_size,
i.width, i.height, i.caption, i.latitude, i.longitude, i.taken_at,
i.camera_make, i.camera_model, i.analysis_status, i.analysis_error,
i.created_at, p.creator_id
FROM images i
JOIN places p ON p.id = i.place_id`
// scanner is satisfied by both *sql.Row and *sql.Rows.
type scanner interface{ Scan(dest ...any) error }
func scanPlace(sc scanner) (domain.Place, error) {
var (
p domain.Place
lat sql.NullFloat64
lon sql.NullFloat64
created timeText
)
err := sc.Scan(&p.ID, &p.Title, &p.Description, &lat, &lon,
&p.LinkRadiusMeters, &created,
&p.Creator.ID, &p.Creator.Username, &p.Creator.Email)
if errors.Is(err, sql.ErrNoRows) {
return domain.Place{}, ErrNotFound
}
if err != nil {
return domain.Place{}, fmt.Errorf("store: scan place: %w", err)
}
if lat.Valid && lon.Valid {
p.Coordinate = &domain.Coordinate{Latitude: lat.Float64, Longitude: lon.Float64}
}
p.CreatedAt = created.Time
return p, nil
}
func scanImage(sc scanner) (domain.Image, error) {
var (
img domain.Image
lat sql.NullFloat64
lon sql.NullFloat64
takenAt nullTimeText
createdAt timeText
status string
creatorID string
)
err := sc.Scan(&img.ID, &img.PlaceID, &img.StorageKey, &img.ContentType, &img.ByteSize,
&img.Width, &img.Height, &img.Caption, &lat, &lon, &takenAt,
&img.CameraMake, &img.CameraModel, &status, &img.AnalysisError, &createdAt, &creatorID)
if errors.Is(err, sql.ErrNoRows) {
return domain.Image{}, ErrNotFound
}
if err != nil {
return domain.Image{}, fmt.Errorf("store: scan image: %w", err)
}
if lat.Valid && lon.Valid {
img.Coordinate = &domain.Coordinate{Latitude: lat.Float64, Longitude: lon.Float64}
}
img.TakenAt = takenAt.Time
img.CreatedAt = createdAt.Time
img.AnalysisStatus = domain.AnalysisStatus(status)
return img, nil
}
// badgesFor loads badges for many pictures in one round trip.
func (s *SQLStore) badgesFor(ctx context.Context, imageIDs []string) (map[string][]domain.Badge, error) {
out := map[string][]domain.Badge{}
if len(imageIDs) == 0 {
return out, nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(imageIDs)), ",")
args := make([]any, len(imageIDs))
for i, id := range imageIDs {
args[i] = id
}
rows, err := s.db.QueryContext(ctx, `
SELECT image_id, label, display, confidence, source
FROM badges
WHERE image_id IN (`+placeholders+`)
ORDER BY confidence DESC, label ASC`, args...)
if err != nil {
return nil, fmt.Errorf("store: load badges: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
imageID string
b domain.Badge
)
if err := rows.Scan(&imageID, &b.Label, &b.Display, &b.Confidence, &b.Source); err != nil {
return nil, fmt.Errorf("store: scan badge: %w", err)
}
out[imageID] = append(out[imageID], b)
}
return out, rows.Err()
}
func newID() string { return uuid.NewString() }
func formatTime(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) }
func nullableFloat(c *domain.Coordinate, get func(domain.Coordinate) float64) any {
if c == nil {
return nil
}
return get(*c)
}
func nullableTime(t *time.Time) any {
if t == nil {
return nil
}
return formatTime(*t)
}
// timeText scans an RFC3339 timestamp column into a time.Time.
type timeText struct{ Time time.Time }
// Scan implements sql.Scanner.
func (t *timeText) Scan(src any) error {
var s string
switch v := src.(type) {
case string:
s = v
case []byte:
s = string(v)
case time.Time:
t.Time = v.UTC()
return nil
case nil:
return errors.New("store: unexpected NULL timestamp")
default:
return fmt.Errorf("store: cannot scan %T into time", src)
}
// Try RFC3339Nano first, then date-only format YYYY-MM-DD
parsedTime := time.Time{}
if parsed, err := time.Parse(time.RFC3339Nano, s); err == nil {
parsedTime = parsed
} else if parsed, err := time.Parse("2006-01-02", s); err == nil {
parsedTime = parsed
} else {
return fmt.Errorf("store: parse timestamp %q: %w", s, nil)
}
t.Time = parsedTime.UTC()
return nil
}
// nullTimeText scans a nullable RFC3339 timestamp column.
type nullTimeText struct{ Time *time.Time }
// Scan implements sql.Scanner.
func (t *nullTimeText) Scan(src any) error {
if src == nil {
t.Time = nil
return nil
}
var inner timeText
if err := inner.Scan(src); err != nil {
return err
}
v := inner.Time
t.Time = &v
return nil
}
// wrapWrite converts driver constraint failures into the package sentinel
// errors so callers can branch on them with errors.Is.
func wrapWrite(err error, entity string) error {
if err == nil {
return nil
}
msg := strings.ToLower(err.Error())
switch {
case strings.Contains(msg, "unique constraint failed"),
strings.Contains(msg, "constraint failed"):
return fmt.Errorf("%w: duplicate %s: %v", ErrConflict, entity, err)
case errors.Is(err, sql.ErrNoRows):
return ErrNotFound
default:
return fmt.Errorf("store: write %s: %w", entity, err)
}
}

116
api/internal/store/store.go Normal file
View file

@ -0,0 +1,116 @@
// Package store defines persistence for the domain entities. The interface
// exists so that the query implementation can be swapped (a different SQL
// dialect, or an in-memory fake in tests) without touching the service layer.
package store
import (
"context"
"errors"
"go-places-api/internal/domain"
"go-places-api/internal/geo"
)
var (
// ErrNotFound is returned when a lookup matches no row.
ErrNotFound = errors.New("not found")
// ErrConflict is returned when a unique constraint would be violated.
ErrConflict = errors.New("conflict")
)
// ImageFilter narrows a picture search.
type ImageFilter struct {
// PlaceID restricts results to one place when non-empty.
PlaceID string
// CreatorID restricts results to one author when non-empty.
CreatorID string
// Within restricts results to pictures inside this box and radius. A nil
// Coordinate disables the geographic restriction.
Within *NearbyQuery
// Limit caps the number of rows. Zero means unlimited.
Limit int
// Offset skips rows for pagination.
Offset int
}
// NearbyQuery selects entities within a radius of a point.
type NearbyQuery struct {
Center domain.Coordinate
RadiusMeters float64
}
// Store is the persistence contract required by the service layer.
type Store interface {
// CreatePlace inserts a place and returns it as persisted.
CreatePlace(ctx context.Context, p domain.Place) (domain.Place, error)
// GetPlace loads a place, its pictures, comments and author.
GetPlace(ctx context.Context, id string) (domain.Place, error)
// UpdatePlace replaces the mutable fields of a place.
UpdatePlace(ctx context.Context, p domain.Place) (domain.Place, error)
// ListPlaces returns places newest first.
ListPlaces(ctx context.Context, limit, offset int) ([]domain.Place, error)
// DeletePlace removes a place and everything attached to it.
DeletePlace(ctx context.Context, id string) error
// CreateImage inserts a picture record.
CreateImage(ctx context.Context, img domain.Image) (domain.Image, error)
// GetImage loads one picture.
GetImage(ctx context.Context, id string) (domain.Image, error)
// GetImageByStorageKey loads the picture a blob key belongs to. The media
// route needs it to learn the declared content type and size of a stored
// object without trusting the file's own bytes.
GetImageByStorageKey(ctx context.Context, key string) (domain.Image, error)
// UpdateImage replaces the mutable fields of a picture.
UpdateImage(ctx context.Context, img domain.Image) (domain.Image, error)
// ListImages returns pictures matching the filter, newest first.
ListImages(ctx context.Context, f ImageFilter) ([]domain.Image, error)
// ImagesWithin returns pictures whose capture point lies inside the radius.
ImagesWithin(ctx context.Context, q NearbyQuery) ([]domain.Image, error)
// ReplaceBadges swaps the badge set for a picture.
ReplaceBadges(ctx context.Context, imageID string, badges []domain.Badge) error
// CreateImageLink records that two pictures were taken at the same place.
// Re-recording the same pair updates the measured distance.
CreateImageLink(ctx context.Context, imageID, relatedImageID string, distanceMeters float64, reason string) error
// ListImageLinks returns the recorded links for a picture in both
// directions, so a link is visible from either end.
ListImageLinks(ctx context.Context, imageID string) ([]domain.Link, error)
// SetAnalysisState records the outcome of the analysis pipeline.
SetAnalysisState(ctx context.Context, imageID string, status domain.AnalysisStatus, failure string) error
// DeleteImage removes a picture and its badges.
DeleteImage(ctx context.Context, id string) error
// AddComment inserts a comment and returns it as persisted.
AddComment(ctx context.Context, c domain.Comment) (domain.Comment, error)
// ListComments returns the comments on a place, oldest first.
ListComments(ctx context.Context, placeID string) ([]domain.Comment, error)
// UpsertUser stores the identity asserted by the auth layer.
UpsertUser(ctx context.Context, u domain.User) error
// GetUser loads a user by identifier.
GetUser(ctx context.Context, id string) (domain.User, error)
// Close releases resources held by the store.
Close() error
}
// BoundingBox converts a nearby query into the coarse window used to pre-filter
// rows in SQL. Exposed here so implementations and tests agree on the filter.
func (q NearbyQuery) BoundingBox() geo.BoundingBox {
return geo.Around(q.Center, q.RadiusMeters)
}
// ErrInvalidInput is returned when a write would violate a domain rule, such as
// linking a picture to itself.
var ErrInvalidInput = errors.New("invalid input")
// ImageGetter is the optional extra surface the service uses for storage-key
// lookups. It is separate from Store so an implementation that cannot map a key
// back to a record simply does not satisfy it.
type ImageGetter interface {
GetImageByStorageKey(ctx context.Context, key string) (domain.Image, error)
}
// Pinger is the optional extra surface used by the readiness probe.
type Pinger interface {
Ping(ctx context.Context) error
}

View file

@ -0,0 +1,667 @@
package store
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"go-places-api/internal/domain"
)
// newTestStore opens a throwaway SQLite database. The foreign_keys pragma is
// requested explicitly because SQLite ignores declared constraints unless it is
// enabled per connection.
func newTestStore(t *testing.T) *SQLStore {
t.Helper()
dsn := "file:" + filepath.Join(t.TempDir(), "go-places.db") +
"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)"
s, err := OpenSQL(dsn)
if err != nil {
t.Fatalf("OpenSQL: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
// seedUser inserts the author every place and comment must reference.
func seedUser(t *testing.T, s *SQLStore, id string) domain.User {
t.Helper()
u := domain.User{ID: id, Username: "ada-" + id, Email: id + "@example.com"}
if err := s.UpsertUser(context.Background(), u); err != nil {
t.Fatalf("UpsertUser: %v", err)
}
return u
}
// seedPlace creates a place at an optional coordinate.
func seedPlace(t *testing.T, s *SQLStore, creator, title string, coord *domain.Coordinate) domain.Place {
t.Helper()
p, err := s.CreatePlace(context.Background(), domain.Place{
Title: title,
Description: "notes for " + title,
Coordinate: coord,
LinkRadiusMeters: 150,
Creator: domain.User{ID: creator},
})
if err != nil {
t.Fatalf("CreatePlace(%q): %v", title, err)
}
return p
}
// seedImage creates a picture belonging to a place.
func seedImage(t *testing.T, s *SQLStore, placeID, key string, coord *domain.Coordinate) domain.Image {
t.Helper()
img, err := s.CreateImage(context.Background(), domain.Image{
PlaceID: placeID,
StorageKey: key,
ContentType: "image/jpeg",
ByteSize: 2048,
Coordinate: coord,
AnalysisStatus: domain.AnalysisPending,
})
if err != nil {
t.Fatalf("CreateImage(%q): %v", key, err)
}
return img
}
func coord(lat, lon float64) *domain.Coordinate {
return &domain.Coordinate{Latitude: lat, Longitude: lon}
}
func TestPlaceRoundTrip(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-1")
created := seedPlace(t, s, user.ID, "Montmartre", coord(48.8867, 2.3431))
if created.ID == "" {
t.Error("CreatePlace did not assign an ID")
}
if created.CreatedAt.IsZero() {
t.Error("CreatedAt was not set")
}
got, err := s.GetPlace(ctx, created.ID)
if err != nil {
t.Fatalf("GetPlace: %v", err)
}
if got.Title != "Montmartre" || got.Description != "notes for Montmartre" {
t.Errorf("round trip lost fields: %+v", got)
}
if got.Coordinate == nil {
t.Fatal("Coordinate was not persisted")
}
if d := got.Coordinate.Latitude - 48.8867; d > 1e-9 || d < -1e-9 {
t.Errorf("Latitude = %v, want 48.8867", got.Coordinate.Latitude)
}
if got.LinkRadiusMeters != 150 {
t.Errorf("LinkRadiusMeters = %v, want 150", got.LinkRadiusMeters)
}
if got.Creator.ID != user.ID {
t.Errorf("Creator.ID = %q, want %q", got.Creator.ID, user.ID)
}
}
func TestPlaceWithoutCoordinateStaysNil(t *testing.T) {
t.Parallel()
// (0,0) is a legitimate coordinate, so "unset" must be represented by nil
// rather than by zero values. Check the round trip does not invent a point.
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-nocoord")
created := seedPlace(t, s, user.ID, "Unknown Spot", nil)
got, err := s.GetPlace(ctx, created.ID)
if err != nil {
t.Fatalf("GetPlace: %v", err)
}
if got.Coordinate != nil {
t.Errorf("Coordinate = %+v, want nil", got.Coordinate)
}
}
func TestGetPlaceMissingReturnsErrNotFound(t *testing.T) {
t.Parallel()
s := newTestStore(t)
if _, err := s.GetPlace(context.Background(), "does-not-exist"); !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestUpdatePlaceReplacesMutableFields(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-2")
created := seedPlace(t, s, user.ID, "Old Title", coord(10, 10))
created.Title = "New Title"
created.Description = "rewritten"
created.LinkRadiusMeters = 42
updated, err := s.UpdatePlace(ctx, created)
if err != nil {
t.Fatalf("UpdatePlace: %v", err)
}
if updated.Title != "New Title" || updated.LinkRadiusMeters != 42 {
t.Errorf("UpdatePlace returned %+v", updated)
}
got, err := s.GetPlace(ctx, created.ID)
if err != nil {
t.Fatalf("GetPlace: %v", err)
}
if got.Title != "New Title" || got.Description != "rewritten" || got.LinkRadiusMeters != 42 {
t.Errorf("stored value did not change: %+v", got)
}
}
func TestListPlacesIsNewestFirstAndPaginates(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-3")
// CreatedAt is stamped by the store, so insert in sequence and rely on the
// sub-millisecond difference the store writes.
const n = 3
for i := range n {
seedPlace(t, s, user.ID, "place-"+string(rune('a'+i)), nil)
time.Sleep(2 * time.Millisecond)
}
all, err := s.ListPlaces(ctx, 0, 0)
if err != nil {
t.Fatalf("ListPlaces: %v", err)
}
if len(all) != n {
t.Fatalf("ListPlaces returned %d places, want %d", len(all), n)
}
for i := 1; i < len(all); i++ {
if all[i-1].CreatedAt.Before(all[i].CreatedAt) {
t.Errorf("results are not newest first: %v then %v", all[i-1].CreatedAt, all[i].CreatedAt)
}
}
page, err := s.ListPlaces(ctx, 1, 0)
if err != nil {
t.Fatalf("ListPlaces(limit=1): %v", err)
}
if len(page) != 1 {
t.Errorf("limit=1 returned %d places", len(page))
}
if page[0].ID != all[0].ID {
t.Errorf("first page = %q, want the newest %q", page[0].ID, all[0].ID)
}
second, err := s.ListPlaces(ctx, 1, 1)
if err != nil {
t.Fatalf("ListPlaces(limit=1,offset=1): %v", err)
}
if len(second) != 1 || second[0].ID != all[1].ID {
t.Errorf("offset did not skip one row: %+v", second)
}
}
func TestDeletePlaceCascadesToImagesCommentsAndLinks(t *testing.T) {
t.Parallel()
// The schema relies on ON DELETE CASCADE. If foreign keys are not enabled the
// rows survive, which would leak every attachment of a deleted place.
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-4")
place := seedPlace(t, s, user.ID, "Doomed", coord(1, 1))
other := seedPlace(t, s, user.ID, "Survivor", coord(1, 1))
a := seedImage(t, s, place.ID, "a.jpg", coord(1, 1))
b := seedImage(t, s, other.ID, "b.jpg", coord(1, 1))
if _, err := s.AddComment(ctx, domain.Comment{
PlaceID: place.ID, Content: "nice", Creator: domain.User{ID: user.ID},
}); err != nil {
t.Fatalf("AddComment: %v", err)
}
if err := s.CreateImageLink(ctx, a.ID, b.ID, 3.5, "same-place"); err != nil {
t.Fatalf("CreateImageLink: %v", err)
}
if err := s.DeletePlace(ctx, place.ID); err != nil {
t.Fatalf("DeletePlace: %v", err)
}
if _, err := s.GetPlace(ctx, place.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("place still readable: err = %v", err)
}
if _, err := s.GetImage(ctx, a.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("image survived the cascade: err = %v", err)
}
comments, err := s.ListComments(ctx, place.ID)
if err != nil {
t.Fatalf("ListComments: %v", err)
}
if len(comments) != 0 {
t.Errorf("%d comments survived the cascade", len(comments))
}
links, err := s.ListImageLinks(ctx, b.ID)
if err != nil {
t.Fatalf("ListImageLinks: %v", err)
}
if len(links) != 0 {
t.Errorf("%d links survived the cascade", len(links))
}
// The unrelated picture must be untouched.
if _, err := s.GetImage(ctx, b.ID); err != nil {
t.Errorf("unrelated image was removed: %v", err)
}
}
func TestImageCarriesMetadataAndBadges(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-5")
place := seedPlace(t, s, user.ID, "With Photo", coord(48, 2))
taken := time.Date(2026, 6, 1, 14, 23, 45, 0, time.UTC)
img, err := s.CreateImage(ctx, domain.Image{
PlaceID: place.ID,
StorageKey: "photos/eiffel.jpg",
ContentType: "image/jpeg",
ByteSize: 4096,
Width: 1920,
Height: 1080,
Caption: "the view",
Coordinate: coord(48.8584, 2.2945),
TakenAt: &taken,
CameraMake: "Canon",
CameraModel: "EOS R6",
})
if err != nil {
t.Fatalf("CreateImage: %v", err)
}
if img.AnalysisStatus != domain.AnalysisPending {
t.Errorf("AnalysisStatus = %q, want %q", img.AnalysisStatus, domain.AnalysisPending)
}
if err := s.ReplaceBadges(ctx, img.ID, []domain.Badge{
{Label: "sky", Display: "Sky", Confidence: 0.91, Source: "local"},
{Label: "water", Display: "Water", Confidence: 0.42, Source: "local"},
}); err != nil {
t.Fatalf("ReplaceBadges: %v", err)
}
got, err := s.GetImage(ctx, img.ID)
if err != nil {
t.Fatalf("GetImage: %v", err)
}
if got.Caption != "the view" || got.Width != 1920 || got.Height != 1080 {
t.Errorf("image fields lost: %+v", got)
}
if got.TakenAt == nil || !got.TakenAt.Equal(taken) {
t.Errorf("TakenAt = %v, want %v", got.TakenAt, taken)
}
if got.CameraMake != "Canon" || got.CameraModel != "EOS R6" {
t.Errorf("camera = %q/%q, want Canon/EOS R6", got.CameraMake, got.CameraModel)
}
if len(got.Badges) != 2 {
t.Fatalf("got %d badges, want 2", len(got.Badges))
}
// Badges come back keyed by label so callers can look one up.
byLabel := map[string]domain.Badge{}
for _, b := range got.Badges {
byLabel[b.Label] = b
}
if sky := byLabel["sky"]; sky.Display != "Sky" || sky.Source != "local" {
t.Errorf("sky badge = %+v", sky)
}
}
func TestReplaceBadgesIsIdempotent(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-6")
place := seedPlace(t, s, user.ID, "Repeated Analysis", nil)
img := seedImage(t, s, place.ID, "r.jpg", nil)
for range 2 {
if err := s.ReplaceBadges(ctx, img.ID, []domain.Badge{
{Label: "sky", Display: "Sky", Confidence: 0.5, Source: "local"},
}); err != nil {
t.Fatalf("ReplaceBadges: %v", err)
}
}
got, err := s.GetImage(ctx, img.ID)
if err != nil {
t.Fatalf("GetImage: %v", err)
}
if len(got.Badges) != 1 {
t.Errorf("ReplaceBadges duplicated rows: got %d badges", len(got.Badges))
}
}
func TestSetAnalysisStateRecordsFailure(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-7")
place := seedPlace(t, s, user.ID, "Failing", nil)
img := seedImage(t, s, place.ID, "f.jpg", nil)
if err := s.SetAnalysisState(ctx, img.ID, domain.AnalysisFailed, "decode failed"); err != nil {
t.Fatalf("SetAnalysisState: %v", err)
}
got, err := s.GetImage(ctx, img.ID)
if err != nil {
t.Fatalf("GetImage: %v", err)
}
if got.AnalysisStatus != domain.AnalysisFailed {
t.Errorf("AnalysisStatus = %q, want %q", got.AnalysisStatus, domain.AnalysisFailed)
}
if got.AnalysisError != "decode failed" {
t.Errorf("AnalysisError = %q", got.AnalysisError)
}
// A later successful run must clear the stale error, otherwise a recovered
// picture keeps reporting why it failed.
if err := s.SetAnalysisState(ctx, img.ID, domain.AnalysisCompleted, ""); err != nil {
t.Fatalf("SetAnalysisState(completed): %v", err)
}
got, err = s.GetImage(ctx, img.ID)
if err != nil {
t.Fatalf("GetImage: %v", err)
}
if got.AnalysisStatus != domain.AnalysisCompleted || got.AnalysisError != "" {
t.Errorf("state = %q/%q, want completed with no error", got.AnalysisStatus, got.AnalysisError)
}
}
func TestGetImageByStorageKey(t *testing.T) {
t.Parallel()
// The media route uses this to serve a blob with the recorded content type
// and size instead of trusting the bytes on disk.
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-8")
place := seedPlace(t, s, user.ID, "Keyed", nil)
want := seedImage(t, s, place.ID, "media/2026/photo.webp", nil)
got, err := s.GetImageByStorageKey(ctx, "media/2026/photo.webp")
if err != nil {
t.Fatalf("GetImageByStorageKey: %v", err)
}
if got.ID != want.ID {
t.Errorf("got image %q, want %q", got.ID, want.ID)
}
if got.ContentType != "image/webp" && got.ContentType != "image/jpeg" {
t.Errorf("ContentType = %q", got.ContentType)
}
if _, err := s.GetImageByStorageKey(ctx, "media/nope.jpg"); !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestImageLinkIsVisibleFromBothEnds(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-9")
place := seedPlace(t, s, user.ID, "Linked", coord(48.85, 2.29))
a := seedImage(t, s, place.ID, "a.jpg", coord(48.8566, 2.2943))
b := seedImage(t, s, place.ID, "b.jpg", coord(48.8570, 2.2950))
if err := s.CreateImageLink(ctx, a.ID, b.ID, 63.2, "same-place"); err != nil {
t.Fatalf("CreateImageLink: %v", err)
}
fromA, err := s.ListImageLinks(ctx, a.ID)
if err != nil {
t.Fatalf("ListImageLinks(a): %v", err)
}
if len(fromA) != 1 {
t.Fatalf("from a: got %d links, want 1", len(fromA))
}
if fromA[0].RelatedImageID != b.ID {
t.Errorf("from a: related = %q, want %q", fromA[0].RelatedImageID, b.ID)
}
if fromA[0].DistanceMeters != 63.2 {
t.Errorf("DistanceMeters = %v, want 63.2", fromA[0].DistanceMeters)
}
if fromA[0].Reason != "same-place" {
t.Errorf("Reason = %q, want same-place", fromA[0].Reason)
}
fromB, err := s.ListImageLinks(ctx, b.ID)
if err != nil {
t.Fatalf("ListImageLinks(b): %v", err)
}
if len(fromB) != 1 || fromB[0].RelatedImageID != a.ID {
t.Errorf("the link is not visible from b: %+v", fromB)
}
}
func TestCreateImageLinkUpdatesDistanceAndRejectsSelfLink(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-10")
place := seedPlace(t, s, user.ID, "Relinked", nil)
a := seedImage(t, s, place.ID, "a.jpg", nil)
b := seedImage(t, s, place.ID, "b.jpg", nil)
if err := s.CreateImageLink(ctx, a.ID, b.ID, 10, "within-radius"); err != nil {
t.Fatalf("CreateImageLink: %v", err)
}
// Re-running the linking pass must not create a duplicate edge.
if err := s.CreateImageLink(ctx, a.ID, b.ID, 12.5, "within-radius"); err != nil {
t.Fatalf("CreateImageLink again: %v", err)
}
links, err := s.ListImageLinks(ctx, a.ID)
if err != nil {
t.Fatalf("ListImageLinks: %v", err)
}
if len(links) != 1 {
t.Fatalf("got %d links, want 1", len(links))
}
if links[0].DistanceMeters != 12.5 {
t.Errorf("DistanceMeters = %v, want the updated 12.5", links[0].DistanceMeters)
}
if err := s.CreateImageLink(ctx, a.ID, a.ID, 0, "same-place"); !errors.Is(err, ErrInvalidInput) {
t.Errorf("self link err = %v, want ErrInvalidInput", err)
}
}
func TestCommentsAreOldestFirstWithAuthor(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-11")
place := seedPlace(t, s, user.ID, "Talked About", nil)
for i, content := range []string{"first", "second", "third"} {
if _, err := s.AddComment(ctx, domain.Comment{
PlaceID: place.ID, Content: content, Creator: domain.User{ID: user.ID},
}); err != nil {
t.Fatalf("AddComment(%d): %v", i, err)
}
}
got, err := s.ListComments(ctx, place.ID)
if err != nil {
t.Fatalf("ListComments: %v", err)
}
if len(got) != 3 {
t.Fatalf("got %d comments, want 3", len(got))
}
want := []string{"first", "second", "third"}
for i, c := range got {
if c.Content != want[i] {
t.Errorf("comment %d = %q, want %q", i, c.Content, want[i])
}
if c.ID == "" {
t.Errorf("comment %d has no ID", i)
}
if c.Creator.ID != user.ID {
t.Errorf("comment %d creator = %q, want %q", i, c.Creator.ID, user.ID)
}
}
}
func TestAddCommentRejectsUnknownAuthor(t *testing.T) {
t.Parallel()
// A comment must not be able to invent an author that does not exist.
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-12")
place := seedPlace(t, s, user.ID, "Guarded", nil)
_, err := s.AddComment(ctx, domain.Comment{
PlaceID: place.ID, Content: "ghost", Creator: domain.User{ID: "nobody"},
})
if err == nil {
t.Fatal("AddComment accepted an unknown author")
}
}
func TestListImagesFiltersByPlaceCreatorAndBox(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
ada := seedUser(t, s, "ada")
bo := seedUser(t, s, "bo")
eiffel := seedPlace(t, s, ada.ID, "Eiffel", coord(48.8584, 2.2945))
louvre := seedPlace(t, s, bo.ID, "Louvre", coord(48.8606, 2.3376))
near := seedImage(t, s, eiffel.ID, "near.jpg", coord(48.8585, 2.2946))
// Roughly 3 km away: outside a 150 m box but inside a 5 km one.
far := seedImage(t, s, louvre.ID, "far.jpg", coord(48.8730, 2.3320))
// No coordinate at all: geographic filters must skip it rather than fail.
noCoord := seedImage(t, s, eiffel.ID, "nolat.jpg", nil)
byPlace, err := s.ListImages(ctx, ImageFilter{PlaceID: eiffel.ID})
if err != nil {
t.Fatalf("ListImages(place): %v", err)
}
if len(byPlace) != 2 {
t.Errorf("place filter returned %d images, want 2", len(byPlace))
}
byCreator, err := s.ListImages(ctx, ImageFilter{CreatorID: ada.ID})
if err != nil {
t.Fatalf("ListImages(creator): %v", err)
}
if len(byCreator) != 2 {
t.Errorf("creator filter returned %d images, want 2", len(byCreator))
}
// Creator and place filters combine.
both, err := s.ListImages(ctx, ImageFilter{PlaceID: eiffel.ID, CreatorID: bo.ID})
if err != nil {
t.Fatalf("ListImages(place+creator): %v", err)
}
if len(both) != 0 {
t.Errorf("combined filter returned %d images, want 0", len(both))
}
tight := NearbyQuery{Center: domain.Coordinate{Latitude: 48.8584, Longitude: 2.2945}, RadiusMeters: 150}
withinTight, err := s.ListImages(ctx, ImageFilter{Within: &tight})
if err != nil {
t.Fatalf("ListImages(within 150m): %v", err)
}
if len(withinTight) != 1 || withinTight[0].ID != near.ID {
t.Errorf("150 m box returned %d images, want just the nearby one", len(withinTight))
}
for _, got := range withinTight {
if got.ID == far.ID {
t.Error("the 3 km image was inside the 150 m box")
}
}
wide := NearbyQuery{Center: domain.Coordinate{Latitude: 48.8584, Longitude: 2.2945}, RadiusMeters: 5000}
withinWide, err := s.ListImages(ctx, ImageFilter{Within: &wide})
if err != nil {
t.Fatalf("ListImages(within 5km): %v", err)
}
if len(withinWide) != 2 {
t.Errorf("5 km box returned %d images, want 2", len(withinWide))
}
ids := map[string]bool{}
for _, got := range withinWide {
ids[got.ID] = true
}
if !ids[near.ID] || !ids[far.ID] {
t.Error("5 km box should contain both the near and the far image")
}
if ids[noCoord.ID] {
t.Error("a picture without a coordinate must never match a geographic filter")
}
}
func TestUpsertUserIsIdempotentAndGetUserReadsBack(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
first := domain.User{ID: "u1", Username: "ada", Email: "ada@example.com"}
if err := s.UpsertUser(ctx, first); err != nil {
t.Fatalf("UpsertUser: %v", err)
}
// The auth layer re-asserts identity on every request, so a second call must
// update rather than fail.
second := domain.User{ID: "u1", Username: "ada-lovelace", Email: "ada@example.com"}
if err := s.UpsertUser(ctx, second); err != nil {
t.Fatalf("UpsertUser again: %v", err)
}
got, err := s.GetUser(ctx, "u1")
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if got.Username != "ada-lovelace" {
t.Errorf("Username = %q, want the updated value", got.Username)
}
if _, err := s.GetUser(ctx, "missing"); !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestPingSucceedsOnOpenStore(t *testing.T) {
t.Parallel()
s := newTestStore(t)
if err := s.Ping(context.Background()); err != nil {
t.Errorf("Ping: %v", err)
}
if err := s.PingContext(context.Background()); err != nil {
t.Errorf("PingContext: %v", err)
}
}