go-places/api/internal/service/service_test.go

837 lines
24 KiB
Go
Raw Permalink Normal View History

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