837 lines
24 KiB
Go
837 lines
24 KiB
Go
|
|
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
|