667 lines
19 KiB
Go
667 lines
19 KiB
Go
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)
|
|
}
|
|
}
|