go-places/api/internal/store/store_test.go

668 lines
19 KiB
Go
Raw Normal View History

2026-10-07 15:14:50 +02:00
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)
}
}