package service import ( "bytes" "context" "errors" "fmt" "image" "image/color" "image/jpeg" "io" "log/slog" "path/filepath" "testing" "go-places-api/internal/analysis" "go-places-api/internal/blobstore" "go-places-api/internal/config" "go-places-api/internal/domain" "go-places-api/internal/events" "go-places-api/internal/store" ) // harness bundles a service with the real collaborators it needs, backed by a // throwaway SQLite database and a temp directory. type harness struct { svc *Service store *store.SQLStore blobs *blobstore.LocalFS user domain.User } // newHarness wires a service the way main does, minus the network. func newHarness(t *testing.T) *harness { t.Helper() return newHarnessWith(t, nil) } // newHarnessWith is newHarness with a hook to adjust the configuration, for // tests that need a different limit or default. func newHarnessWith(t *testing.T, tweak func(*config.Config)) *harness { t.Helper() st, err := store.OpenSQL("file:" + filepath.Join(t.TempDir(), "test.db") + "?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)") if err != nil { t.Fatalf("store.OpenSQL: %v", err) } t.Cleanup(func() { st.Close() }) blobs, err := blobstore.NewLocalFS(t.TempDir()) if err != nil { t.Fatalf("blobstore.NewLocalFS: %v", err) } cfg := config.Default() if tweak != nil { tweak(&cfg) } user := domain.User{ID: "user-1", Username: "ada", Email: "ada@example.com"} svc := New(Options{ Store: st, Blobs: blobs, Analyzer: analysis.NewLocal(analysis.DefaultMaxDimension), Events: events.NewLogPublisher(slog.New(slog.DiscardHandler)), Config: cfg, }) return &harness{svc: svc, store: st, blobs: blobs, user: user} } // createPlace adds a place owned by the harness user. func (h *harness) createPlace(t *testing.T, title string, lat, lon float64) domain.Place { t.Helper() in := domain.PlaceInput{Title: title, Description: "notes"} if lat != 0 || lon != 0 { in.Latitude, in.Longitude = &lat, &lon } p, err := h.svc.CreatePlace(context.Background(), h.user, in) if err != nil { t.Fatalf("CreatePlace(%q): %v", title, err) } return p } // jpegBytes encodes a solid-colour JPEG of the given size. func jpegBytes(t *testing.T, w, h int, c color.Color) []byte { t.Helper() img := image.NewRGBA(image.Rect(0, 0, w, h)) for y := range h { for x := range w { img.SetRGBA(x, y, c.(color.RGBA)) } } var buf bytes.Buffer if err := jpeg.Encode(&buf, img, nil); err != nil { t.Fatalf("jpeg.Encode: %v", err) } return buf.Bytes() } // skyBlue is easy for the local analyzer to recognise as sky. var skyBlue = color.RGBA{R: 40, G: 120, B: 220, A: 255} // upload adds one encoded picture to a place. func (h *harness) upload(t *testing.T, placeID string, name string, data []byte, c *domain.Coordinate) domain.Image { t.Helper() img, err := h.svc.AddImage(context.Background(), h.user, placeID, ImageUpload{ Filename: name, ContentType: "image/jpeg", Caption: "a caption", Body: bytes.NewReader(data), Coordinate: c, }) if err != nil { t.Fatalf("AddImage(%q): %v", name, err) } return img } func TestCreatePlaceAppliesDefaultsAndPersists(t *testing.T) { t.Parallel() h := newHarness(t) lat, lon := 48.8584, 2.2945 p, err := h.svc.CreatePlace(context.Background(), h.user, domain.PlaceInput{ Title: " Eiffel Tower ", Description: " went up it ", Latitude: &lat, Longitude: &lon, }) if err != nil { t.Fatalf("CreatePlace: %v", err) } // Whitespace is trimmed so a client that pads its input gets clean output. if p.Title != "Eiffel Tower" { t.Errorf("Title = %q, want %q", p.Title, "Eiffel Tower") } if p.Description != "went up it" { t.Errorf("Description = %q", p.Description) } if p.LinkRadiusMeters != config.Default().DefaultLinkRadiusMeters { t.Errorf("LinkRadiusMeters = %v, want the default %v", p.LinkRadiusMeters, config.Default().DefaultLinkRadiusMeters) } if p.Coordinate == nil || p.Coordinate.Latitude != lat { t.Errorf("Coordinate = %+v, want %v", p.Coordinate, lat) } if p.Creator.ID != h.user.ID { t.Errorf("Creator = %+v, want %q", p.Creator, h.user.ID) } if p.ID == "" { t.Error("CreatePlace returned no ID") } } func TestCreatePlaceRejectsBadInput(t *testing.T) { t.Parallel() h := newHarness(t) ctx := context.Background() cfg := config.Default() tests := []struct { name string in domain.PlaceInput }{ {name: "no title", in: domain.PlaceInput{}}, {name: "blank title", in: domain.PlaceInput{Title: " "}}, {name: "title too long", in: domain.PlaceInput{Title: string(make([]byte, maxTitleLength+1))}}, {name: "description too long", in: domain.PlaceInput{ Title: "ok", Description: string(make([]byte, maxDescriptionLength+1)), }}, {name: "zero radius", in: domain.PlaceInput{Title: "ok", LinkRadiusMeters: ptr(0.0)}}, {name: "negative radius", in: domain.PlaceInput{Title: "ok", LinkRadiusMeters: ptr(-10.0)}}, // A client must not be able to opt out of the radius cap. {name: "radius above cap", in: domain.PlaceInput{ Title: "ok", LinkRadiusMeters: ptr(cfg.MaxLinkRadiusMeters + 1), }}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() _, err := h.svc.CreatePlace(ctx, h.user, tc.in) if !errors.Is(err, ErrInvalidInput) { t.Errorf("err = %v, want ErrInvalidInput", err) } }) } } func TestCreatePlaceHonoursExplicitRadius(t *testing.T) { t.Parallel() h := newHarness(t) radius := 750.0 p, err := h.svc.CreatePlace(context.Background(), h.user, domain.PlaceInput{ Title: "Wide", LinkRadiusMeters: &radius, }) if err != nil { t.Fatalf("CreatePlace: %v", err) } if p.LinkRadiusMeters != radius { t.Errorf("LinkRadiusMeters = %v, want %v", p.LinkRadiusMeters, radius) } } func TestAddImageStoresAnalysesAndLinks(t *testing.T) { t.Parallel() h := newHarness(t) ctx := context.Background() place := h.createPlace(t, "Eiffel", 48.8584, 2.2945) data := jpegBytes(t, 64, 48, skyBlue) img := h.upload(t, place.ID, "sky.jpg", data, nil) if img.ID == "" { t.Error("no image ID assigned") } if img.ByteSize != int64(len(data)) { t.Errorf("ByteSize = %d, want %d", img.ByteSize, len(data)) } if img.Width != 64 || img.Height != 48 { t.Errorf("dimensions = %dx%d, want 64x48", img.Width, img.Height) } if img.AnalysisStatus != domain.AnalysisCompleted { t.Errorf("AnalysisStatus = %q, want %q", img.AnalysisStatus, domain.AnalysisCompleted) } if len(img.Badges) == 0 { t.Error("a plain blue picture should produce at least one badge") } if img.Caption != "a caption" { t.Errorf("Caption = %q", img.Caption) } // The blob must be readable under a key derived from the author, and the // client-facing URL must not leak the storage key. if img.URL == "" { t.Error("URL was not set") } if img.URL == img.StorageKey { t.Error("URL should not be the raw storage key") } rc, err := h.blobs.Get(ctx, img.StorageKey) if err != nil { t.Fatalf("blobs.Get(%q): %v", img.StorageKey, err) } defer rc.Close() stored, err := io.ReadAll(rc) if err != nil { t.Fatalf("read blob: %v", err) } if !bytes.Equal(stored, data) { t.Errorf("stored %d bytes, want the %d uploaded", len(stored), len(data)) } } func TestAddImageRejectsUnusableUploads(t *testing.T) { t.Parallel() h := newHarness(t) ctx := context.Background() place := h.createPlace(t, "Guarded", 48.85, 2.29) good := jpegBytes(t, 8, 8, skyBlue) tests := []struct { name string up ImageUpload want error }{ {name: "no place", up: ImageUpload{ContentType: "image/jpeg", Body: bytes.NewReader(good)}, want: ErrInvalidInput}, {name: "no body", up: ImageUpload{ContentType: "image/jpeg"}, want: ErrInvalidInput}, {name: "empty body", up: ImageUpload{ContentType: "image/jpeg", Body: bytes.NewReader(nil)}, want: ErrInvalidInput}, {name: "no author", up: ImageUpload{ContentType: "image/jpeg", Body: bytes.NewReader(good)}, want: ErrInvalidInput}, {name: "bad content type", up: ImageUpload{ContentType: "text/plain", Body: bytes.NewReader(good)}, want: ErrUnsupportedType}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() author := h.user placeID := place.ID if tc.name == "no author" { author = domain.User{} } if tc.name == "no place" { placeID = "" } _, err := h.svc.AddImage(ctx, author, placeID, tc.up) if !errors.Is(err, tc.want) { t.Errorf("err = %v, want %v", err, tc.want) } }) } } func TestAddImageRejectsUnknownPlace(t *testing.T) { t.Parallel() h := newHarness(t) _, err := h.svc.AddImage(context.Background(), h.user, "no-such-place", ImageUpload{ ContentType: "image/jpeg", Body: bytes.NewReader(jpegBytes(t, 8, 8, skyBlue)), }) if !errors.Is(err, ErrNotFound) { t.Errorf("err = %v, want ErrNotFound", err) } } func TestAddImageRejectsUndecodablePayload(t *testing.T) { t.Parallel() // A payload with a valid content type but corrupt bytes must not reach // storage, because analysis runs first. h := newHarness(t) ctx := context.Background() place := h.createPlace(t, "Corrupt", 10, 10) _, err := h.svc.AddImage(ctx, h.user, place.ID, ImageUpload{ Filename: "fake.jpg", ContentType: "image/jpeg", Body: bytes.NewReader([]byte("this is definitely not a JPEG")), }) if !errors.Is(err, ErrInvalidInput) { t.Fatalf("err = %v, want ErrInvalidInput", err) } images, err := h.store.ListImages(ctx, store.ImageFilter{PlaceID: place.ID}) if err != nil { t.Fatalf("ListImages: %v", err) } if len(images) != 0 { t.Errorf("%d images were stored for an undecodable upload", len(images)) } } func TestAddImageEnforcesMaxUploadBytes(t *testing.T) { t.Parallel() // The limit must be enforced by reading the stream, not by trusting a // declared Content-Length, so a body over the limit is rejected even though // the JPEG itself decodes fine. h := newHarnessWith(t, func(c *config.Config) { c.MaxUploadBytes = 2048 }) ctx := context.Background() place := h.createPlace(t, "Sized", 10, 10) valid := jpegBytes(t, 32, 32, skyBlue) oversized := append(append([]byte(nil), valid...), make([]byte, 8<<10)...) if _, err := h.svc.AddImage(ctx, h.user, place.ID, ImageUpload{ Filename: "huge.jpg", ContentType: "image/jpeg", Body: bytes.NewReader(oversized), }); !errors.Is(err, ErrTooLarge) { t.Fatalf("err = %v, want ErrTooLarge", err) } // A body at or under the limit is still accepted, so the check is not simply // rejecting anything that does not fit in a fixed buffer. small := jpegBytes(t, 8, 8, skyBlue) if int64(len(small)) >= 2048 { t.Skipf("the encoder produced %d bytes, over the 2048 byte test limit", len(small)) } if _, err := h.svc.AddImage(ctx, h.user, place.ID, ImageUpload{ Filename: "small.jpg", ContentType: "image/jpeg", Body: bytes.NewReader(small), }); err != nil { t.Errorf("a body under the limit was rejected: %v", err) } } func TestAddImagePrefersExplicitCoordinateThenFallsBackToPlace(t *testing.T) { t.Parallel() h := newHarness(t) place := h.createPlace(t, "Located", 48.8584, 2.2945) data := jpegBytes(t, 16, 16, skyBlue) // No override: the picture inherits the place's location so it can be linked. inherited := h.upload(t, place.ID, "inherited.jpg", data, nil) if inherited.Coordinate == nil { t.Fatal("picture with no override has no coordinate") } if d := inherited.Coordinate.Latitude - 48.8584; d > 1e-6 || d < -1e-6 { t.Errorf("Latitude = %v, want the place's 48.8584", inherited.Coordinate.Latitude) } // An explicit override wins over the place. override := domain.Coordinate{Latitude: 10, Longitude: 20} explicit := h.upload(t, place.ID, "explicit.jpg", data, &override) if explicit.Coordinate == nil || explicit.Coordinate.Latitude != 10 { t.Errorf("Coordinate = %+v, want the override 10/20", explicit.Coordinate) } } func TestPicturesInOnePlaceAreLinkedToEachOther(t *testing.T) { t.Parallel() h := newHarness(t) ctx := context.Background() place := h.createPlace(t, "Same Place", 48.8584, 2.2945) data := jpegBytes(t, 16, 16, skyBlue) first := h.upload(t, place.ID, "a.jpg", data, nil) second := h.upload(t, place.ID, "b.jpg", data, nil) links, err := h.svc.LinksForImage(ctx, second.ID) if err != nil { t.Fatalf("LinksForImage: %v", err) } if len(links) != 1 { t.Fatalf("got %d links, want 1", len(links)) } if links[0].RelatedImageID != first.ID { t.Errorf("linked to %q, want %q", links[0].RelatedImageID, first.ID) } if !links[0].SamePlace { t.Error("pictures sharing a place record should report SamePlace") } if links[0].Reason != ReasonSamePlace { t.Errorf("Reason = %q, want %q", links[0].Reason, ReasonSamePlace) } } func TestPicturesAtTheSameCoordinatesAcrossPlacesAreLinked(t *testing.T) { t.Parallel() // The headline feature: the same spot catalogued under two separate places // should still be recognised as the same location. h := newHarness(t) ctx := context.Background() data := jpegBytes(t, 16, 16, skyBlue) firstPlace := h.createPlace(t, "Eiffel (visit 1)", 48.8584, 2.2945) secondPlace := h.createPlace(t, "Eiffel (visit 2)", 48.8585, 2.2946) a := h.upload(t, firstPlace.ID, "a.jpg", data, nil) b := h.upload(t, secondPlace.ID, "b.jpg", data, nil) links, err := h.svc.LinksForImage(ctx, b.ID) if err != nil { t.Fatalf("LinksForImage: %v", err) } if len(links) != 1 { t.Fatalf("got %d links, want 1", len(links)) } if links[0].RelatedImageID != a.ID { t.Errorf("linked to %q, want %q", links[0].RelatedImageID, a.ID) } if links[0].SamePlace { t.Error("pictures in different places should not report SamePlace") } if links[0].Reason != ReasonWithinRadius { t.Errorf("Reason = %q, want %q", links[0].Reason, ReasonWithinRadius) } // A few tens of metres apart, so the distance should be small but non-zero. if links[0].DistanceMeters <= 0 || links[0].DistanceMeters > 150 { t.Errorf("DistanceMeters = %v, want a small positive distance", links[0].DistanceMeters) } } func TestPicturesBeyondRadiusAreNotLinked(t *testing.T) { t.Parallel() // Roughly 3 km apart with the default 150 m radius: not the same place. h := newHarness(t) ctx := context.Background() data := jpegBytes(t, 16, 16, skyBlue) near := h.createPlace(t, "Near Spot", 48.8584, 2.2945) far := h.createPlace(t, "Far Spot", 48.8850, 2.3000) h.upload(t, near.ID, "near.jpg", data, nil) other := h.upload(t, far.ID, "far.jpg", data, nil) links, err := h.svc.LinksForImage(ctx, other.ID) if err != nil { t.Fatalf("LinksForImage: %v", err) } if len(links) != 0 { t.Errorf("got %d links between points 3 km apart, want 0: %+v", len(links), links) } } func TestWideRadiusLinksDistantPictures(t *testing.T) { t.Parallel() // A place that opts into a 5 km radius should match a spot ~3 km away. h := newHarness(t) ctx := context.Background() data := jpegBytes(t, 16, 16, skyBlue) radius := 5000.0 in := domain.PlaceInput{Title: "Wide Radius", LinkRadiusMeters: &radius} latA, lonA := 48.8584, 2.2945 in.Latitude, in.Longitude = &latA, &lonA wide, err := h.svc.CreatePlace(ctx, h.user, in) if err != nil { t.Fatalf("CreatePlace: %v", err) } near := h.createPlace(t, "Nearby", 48.8584, 2.2945) far := h.createPlace(t, "Distant", 48.8850, 2.3000) h.upload(t, near.ID, "near.jpg", data, nil) other := h.upload(t, far.ID, "far.jpg", data, nil) // The link is evaluated from the wide place's perspective. wideImg := h.upload(t, wide.ID, "wide.jpg", data, nil) links, err := h.svc.LinksForImage(ctx, wideImg.ID) if err != nil { t.Fatalf("LinksForImage: %v", err) } if len(links) == 0 { t.Fatal("a 5 km radius should link pictures ~3 km apart") } if other.ID == "" { t.Fatal("test setup did not produce the distant picture") } } func TestLinksForIgnoresSelfAndNeedsACentre(t *testing.T) { t.Parallel() img := domain.Image{ID: "a", PlaceID: "p"} same := domain.Image{ID: "a", PlaceID: "p"} place := domain.Place{ID: "p", LinkRadiusMeters: 150} // No coordinate anywhere: there is nothing to compare. if links := LinksFor(img, []domain.Image{same}, place, 150); len(links) != 0 { t.Errorf("got %d links without a centre, want 0", len(links)) } // A non-positive radius disables linking. if links := LinksFor(img, nil, place, 0); len(links) != 0 { t.Errorf("got %d links with radius 0, want 0", len(links)) } } func TestUploadPicturesReportsFailuresPerPicture(t *testing.T) { t.Parallel() // One bad file in a batch must not discard the good ones. h := newHarness(t) ctx := context.Background() place := h.createPlace(t, "Batch", 10, 10) good := jpegBytes(t, 16, 16, skyBlue) results, err := h.svc.UploadPictures(ctx, h.user, place.ID, []ImageUpload{ {Filename: "ok.jpg", ContentType: "image/jpeg", Body: bytes.NewReader(good)}, {Filename: "bad.jpg", ContentType: "image/jpeg", Body: bytes.NewReader([]byte("nope"))}, {Filename: "ok2.jpg", ContentType: "image/jpeg", Body: bytes.NewReader(good)}, }) if err != nil { t.Fatalf("UploadPictures returned a batch error: %v", err) } if len(results) != 3 { t.Fatalf("got %d results, want 3", len(results)) } if results[0].Err != nil { t.Errorf("result 0: %v", results[0].Err) } if !errors.Is(results[1].Err, ErrInvalidInput) { t.Errorf("result 1 err = %v, want ErrInvalidInput", results[1].Err) } if results[2].Err != nil { t.Errorf("result 2: %v", results[2].Err) } images, err := h.store.ListImages(ctx, store.ImageFilter{PlaceID: place.ID}) if err != nil { t.Fatalf("ListImages: %v", err) } if len(images) != 2 { t.Errorf("%d images stored, want the 2 valid ones", len(images)) } } func TestUploadPicturesValidatesTheBatch(t *testing.T) { t.Parallel() h := newHarness(t) ctx := context.Background() place := h.createPlace(t, "Batch Guards", 10, 10) if _, err := h.svc.UploadPictures(ctx, h.user, "", []ImageUpload{ {ContentType: "image/jpeg", Body: bytes.NewReader(jpegBytes(t, 8, 8, skyBlue))}, }); !errors.Is(err, ErrInvalidInput) { t.Errorf("empty place id: err = %v, want ErrInvalidInput", err) } if _, err := h.svc.UploadPictures(ctx, h.user, place.ID, nil); !errors.Is(err, ErrInvalidInput) { t.Errorf("no uploads: err = %v, want ErrInvalidInput", err) } if _, err := h.svc.UploadPictures(ctx, domain.User{}, place.ID, []ImageUpload{ {ContentType: "image/jpeg", Body: bytes.NewReader(jpegBytes(t, 8, 8, skyBlue))}, }); !errors.Is(err, ErrInvalidInput) { t.Errorf("no author: err = %v, want ErrInvalidInput", err) } } func TestReanalyzeRecoversBadges(t *testing.T) { t.Parallel() h := newHarness(t) ctx := context.Background() place := h.createPlace(t, "Reanalyzed", 10, 10) data := jpegBytes(t, 32, 32, skyBlue) img := h.upload(t, place.ID, "sky.jpg", data, nil) // Simulate an earlier failure that left the record without badges. if err := h.store.ReplaceBadges(ctx, img.ID, nil); err != nil { t.Fatalf("ReplaceBadges: %v", err) } got, err := h.svc.Reanalyze(ctx, img.ID) if err != nil { t.Fatalf("Reanalyze: %v", err) } if got.AnalysisStatus != domain.AnalysisCompleted { t.Errorf("AnalysisStatus = %q", got.AnalysisStatus) } if len(got.Badges) == 0 { t.Error("Reanalyze did not produce badges") } } func TestReanalyzeMissingImage(t *testing.T) { t.Parallel() h := newHarness(t) if _, err := h.svc.Reanalyze(context.Background(), "nope"); !errors.Is(err, ErrNotFound) { t.Errorf("err = %v, want ErrNotFound", err) } } func TestOpenImageReturnsStoredBytes(t *testing.T) { t.Parallel() h := newHarness(t) ctx := context.Background() place := h.createPlace(t, "Served", 10, 10) data := jpegBytes(t, 16, 16, skyBlue) img := h.upload(t, place.ID, "served.jpg", data, nil) served, rc, err := h.svc.OpenImage(ctx, img.ID) if err != nil { t.Fatalf("OpenImage: %v", err) } if served.ID != img.ID { t.Errorf("OpenImage returned %q, want %q", served.ID, img.ID) } defer rc.Close() got, err := io.ReadAll(rc) if err != nil { t.Fatalf("read: %v", err) } if !bytes.Equal(got, data) { t.Errorf("got %d bytes, want %d", len(got), len(data)) } } func TestDeletePlaceRemovesBlobs(t *testing.T) { t.Parallel() // Deleting a place has to delete its blobs too, or storage grows forever. h := newHarness(t) ctx := context.Background() place := h.createPlace(t, "Temporary", 10, 10) data := jpegBytes(t, 16, 16, skyBlue) img := h.upload(t, place.ID, "temp.jpg", data, nil) if err := h.svc.DeletePlace(ctx, place.ID); err != nil { t.Fatalf("DeletePlace: %v", err) } if _, err := h.blobs.Get(ctx, img.StorageKey); err == nil { t.Error("the blob survived the place deletion") } if _, err := h.svc.GetPlace(ctx, place.ID); !errors.Is(err, ErrNotFound) { t.Errorf("err = %v, want ErrNotFound", err) } } func TestDeleteImageRemovesBlobAndRecord(t *testing.T) { t.Parallel() h := newHarness(t) ctx := context.Background() place := h.createPlace(t, "Disposable", 10, 10) data := jpegBytes(t, 16, 16, skyBlue) img := h.upload(t, place.ID, "one.jpg", data, nil) if err := h.svc.DeleteImage(ctx, img.ID); err != nil { t.Fatalf("DeleteImage: %v", err) } if _, err := h.blobs.Get(ctx, img.StorageKey); err == nil { t.Error("the blob survived the image deletion") } if _, err := h.svc.ImageByID(ctx, img.ID); !errors.Is(err, ErrNotFound) { t.Errorf("err = %v, want ErrNotFound", err) } } func TestPingSucceeds(t *testing.T) { t.Parallel() h := newHarness(t) if err := h.svc.Ping(context.Background()); err != nil { t.Errorf("Ping: %v", err) } } func TestStorageKeyIsScopedToAuthorAndDate(t *testing.T) { t.Parallel() // The key layout must not let a client choose where bytes land. h := newHarness(t) ctx := context.Background() place := h.createPlace(t, "Keyed", 10, 10) img := h.upload(t, place.ID, "../../../etc/passwd.jpg", jpegBytes(t, 8, 8, skyBlue), nil) if !hasPathPrefix(img.StorageKey, h.user.ID) { t.Errorf("StorageKey = %q, want it under the author %q", img.StorageKey, h.user.ID) } if containsDotDot(img.StorageKey) { t.Errorf("StorageKey = %q contains a traversal segment", img.StorageKey) } _, err := h.svc.GetPlace(ctx, place.ID) if err != nil { t.Fatalf("GetPlace: %v", err) } } func TestExtensionComesFromTypeNotFilename(t *testing.T) { t.Parallel() // A client-supplied name must not be able to choose the stored extension. h := newHarness(t) place := h.createPlace(t, "Extensions", 10, 10) img := h.upload(t, place.ID, "payload.php", jpegBytes(t, 8, 8, skyBlue), nil) if got := img.StorageKey; !hasSuffix(got, ".jpg") { t.Errorf("StorageKey = %q, want a .jpg extension", got) } } func TestBadgeLabelsAreDistinct(t *testing.T) { t.Parallel() got := badgeLabels([]domain.Badge{ {Label: "sky"}, {Label: "water"}, {Label: "sky"}, {Label: ""}, }) seen := map[string]bool{} for _, l := range got { if l == "" { t.Error("an empty label was returned") } if seen[l] { t.Errorf("label %q appeared twice", l) } seen[l] = true } if len(got) != 2 { t.Errorf("got %v, want 2 distinct labels", got) } } func TestImageURLDoesNotEscapeBase(t *testing.T) { t.Parallel() h := newHarness(t) place := h.createPlace(t, "URLs", 10, 10) img := h.upload(t, place.ID, "u.jpg", jpegBytes(t, 8, 8, skyBlue), nil) if img.URL == "" { t.Fatal("URL is empty") } if containsDotDot(img.URL) { t.Errorf("URL = %q contains a traversal segment", img.URL) } } func TestRelatedPlacesFindsOverlap(t *testing.T) { t.Parallel() // Two places whose pictures sit at the same spot should be reported as // related, which is how the UI suggests merging duplicate places. h := newHarness(t) ctx := context.Background() data := jpegBytes(t, 16, 16, skyBlue) first := h.createPlace(t, "Dupe A", 48.8584, 2.2945) second := h.createPlace(t, "Dupe B", 48.8585, 2.2946) h.upload(t, first.ID, "a.jpg", data, nil) h.upload(t, second.ID, "b.jpg", data, nil) related, err := h.svc.RelatedPlaces(ctx, first.ID) if err != nil { t.Fatalf("RelatedPlaces: %v", err) } if len(related) != 1 { t.Fatalf("got %d related places, want 1", len(related)) } if related[0].Place.ID != second.ID { t.Errorf("related place = %q, want %q", related[0].Place.ID, second.ID) } if len(related[0].SharedImageIDs) == 0 { t.Error("expected shared image evidence") } if related[0].DistanceMeters > 150 { t.Errorf("DistanceMeters = %v, want a small distance", related[0].DistanceMeters) } } // ptr returns a pointer to v, for the optional PlaceInput fields. func ptr[T any](v T) *T { return &v } func hasPathPrefix(s, prefix string) bool { return len(s) > len(prefix) && s[:len(prefix)+1] == prefix+"/" } func hasSuffix(s, suffix string) bool { return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix } func containsDotDot(s string) bool { return bytes.Contains([]byte(s), []byte("..")) } var _ = fmt.Sprintf // keep fmt available for ad-hoc debugging in this file