initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
836
api/internal/service/service_test.go
Normal file
836
api/internal/service/service_test.go
Normal file
|
|
@ -0,0 +1,836 @@
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue