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) } }