initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:14:50 +02:00
commit 1253955070
75 changed files with 13972 additions and 0 deletions

View file

@ -0,0 +1,29 @@
package service
import (
"encoding/json"
"fmt"
"sort"
"go-places-api/internal/domain"
)
// marshalPayload encodes an event payload as JSON.
func marshalPayload(payload map[string]any) (json.RawMessage, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("service: marshal event payload: %w", err)
}
return json.RawMessage(body), nil
}
// sortRelated orders related places by distance, then by identifier so the
// result is stable for equal distances.
func sortRelated(in []domain.RelatedPlace) {
sort.SliceStable(in, func(i, j int) bool {
if in[i].DistanceMeters != in[j].DistanceMeters {
return in[i].DistanceMeters < in[j].DistanceMeters
}
return in[i].Place.ID < in[j].Place.ID
})
}

View file

@ -0,0 +1,357 @@
package service
import (
"bytes"
"context"
"fmt"
"io"
"path"
"strings"
"time"
"github.com/google/uuid"
"go-places-api/internal/analysis"
"go-places-api/internal/blobstore"
"go-places-api/internal/domain"
"go-places-api/internal/events"
)
// ImageUpload is one picture submitted by a client.
type ImageUpload struct {
// Filename is the client-supplied name, used only to pick a storage
// extension. It never becomes part of the storage path.
Filename string
// ContentType is the declared MIME type.
ContentType string
// Caption is the description the user attached to this picture.
Caption string
// Body streams the encoded picture.
Body io.Reader
// Coordinate overrides the location. When nil the EXIF GPS tag is used,
// and failing that the place's own coordinate applies.
Coordinate *domain.Coordinate
}
// UploadResult reports what happened to one picture when a batch is submitted.
// Pictures are processed independently so one bad file does not discard the
// good ones.
type UploadResult struct {
Image domain.Image
// AnalysisNote explains a degraded analysis, if any.
AnalysisNote string
// Err is the reason this picture was rejected.
Err error
}
// AddImage stores one picture against a place, analyses it to produce badges,
// and links it to other pictures taken at the same place.
//
// The picture is analysed before its bytes are committed: if the payload is not
// a decodable image there is no reason to keep it. Analysis failures are
// recorded on the record rather than discarding an otherwise valid upload,
// because the picture is the valuable part and the badges can be recomputed
// with Reanalyze.
func (s *Service) AddImage(ctx context.Context, author domain.User, placeID string, up ImageUpload) (domain.Image, error) {
img, _, err := s.addImage(ctx, author, placeID, up)
return img, err
}
// UploadPictures attaches several pictures to a place in one call. Each picture
// is independent, so failures are reported per picture instead of failing the
// whole batch.
func (s *Service) UploadPictures(ctx context.Context, author domain.User, placeID string, uploads []ImageUpload) ([]UploadResult, error) {
if strings.TrimSpace(placeID) == "" {
return nil, fmt.Errorf("%w: place id is required", ErrInvalidInput)
}
if len(uploads) == 0 {
return nil, fmt.Errorf("%w: no pictures were supplied", ErrInvalidInput)
}
if author.ID == "" {
return nil, fmt.Errorf("%w: an authenticated author is required", ErrInvalidInput)
}
results := make([]UploadResult, 0, len(uploads))
for _, up := range uploads {
img, note, err := s.addImage(ctx, author, placeID, up)
results = append(results, UploadResult{Image: img, AnalysisNote: note, Err: err})
if err != nil {
s.logger.Warn("rejected upload", "placeId", placeID, "filename", up.Filename, "error", err)
}
}
return results, nil
}
// addImage is the shared implementation behind AddImage and UploadPictures.
func (s *Service) addImage(ctx context.Context, author domain.User, placeID string, up ImageUpload) (domain.Image, string, error) {
if strings.TrimSpace(placeID) == "" {
return domain.Image{}, "", fmt.Errorf("%w: place id is required", ErrInvalidInput)
}
if author.ID == "" {
return domain.Image{}, "", fmt.Errorf("%w: an authenticated author is required", ErrInvalidInput)
}
if up.Body == nil {
return domain.Image{}, "", fmt.Errorf("%w: the picture is empty", ErrInvalidInput)
}
if !s.cfg.AllowsContentType(up.ContentType) {
return domain.Image{}, "", fmt.Errorf("%w: %q is not an accepted image type",
ErrUnsupportedType, up.ContentType)
}
// Buffer one byte past the limit so an oversized payload is detectable
// without trusting the client's Content-Length.
var buf bytes.Buffer
read, err := io.Copy(&buf, io.LimitReader(up.Body, s.cfg.MaxUploadBytes+1))
if err != nil {
return domain.Image{}, "", fmt.Errorf("%w: the picture could not be read: %v", ErrInvalidInput, err)
}
if read == 0 {
return domain.Image{}, "", fmt.Errorf("%w: the picture is empty", ErrInvalidInput)
}
if read > s.cfg.MaxUploadBytes {
return domain.Image{}, "", fmt.Errorf("%w: the picture exceeds the %d byte limit",
ErrTooLarge, s.cfg.MaxUploadBytes)
}
data := buf.Bytes()
// Analysing first means an undecodable payload never reaches the store.
result, err := s.analyze.Analyze(ctx, analysis.Input{
ContentType: up.ContentType,
Bytes: data,
})
if err != nil {
return domain.Image{}, "", fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
place, err := s.store.GetPlace(ctx, placeID)
if err != nil {
return domain.Image{}, "", mapStoreError(err, "place")
}
if err := s.store.UpsertUser(ctx, author); err != nil {
return domain.Image{}, "", mapStoreError(err, "user")
}
ext := extensionFor(up.Filename, up.ContentType)
key := path.Join(author.ID, time.Now().UTC().Format("2006/01"), uuid.NewString()+ext)
written, err := s.blobs.Put(ctx, key, bytes.NewReader(data), s.cfg.MaxUploadBytes)
if err != nil {
if isTooLarge(err) {
return domain.Image{}, "", fmt.Errorf("%w: %v", ErrTooLarge, err)
}
return domain.Image{}, "", fmt.Errorf("service: store picture: %w", err)
}
// Precedence for a picture's location: an explicit override, then the EXIF
// GPS tag, then the place's own coordinate.
coordinate := effectiveCoordinate(domain.Image{Coordinate: up.Coordinate}, place.Coordinate)
if up.Coordinate == nil && result.Metadata.Coordinate != nil {
coordinate = result.Metadata.Coordinate
}
img := domain.Image{
PlaceID: place.ID,
StorageKey: key,
Caption: strings.TrimSpace(up.Caption),
ContentType: up.ContentType,
ByteSize: written,
Width: result.Width,
Height: result.Height,
Coordinate: coordinate,
TakenAt: result.Metadata.TakenAt,
CameraMake: result.Metadata.Make,
CameraModel: result.Metadata.Model,
Badges: result.Badges,
AnalysisStatus: domain.AnalysisCompleted,
AnalysisError: result.Note,
}
saved, err := s.store.CreateImage(ctx, img)
if err != nil {
// Never leave an orphaned blob behind a failed insert.
if delErr := s.blobs.Delete(ctx, key); delErr != nil {
s.logger.Warn("failed to roll back image blob", "key", key, "error", delErr)
}
return domain.Image{}, "", mapStoreError(err, "image")
}
saved.URL = s.ImageURL(saved.StorageKey)
s.publish(ctx, author, events.TopicImageAnalyzed, map[string]any{
"imageId": saved.ID,
"placeId": place.ID,
"badges": badgeLabels(saved.Badges),
"degraded": result.Note != "",
})
// Linking is separate from accepting the upload, so a failure here is logged
// rather than reported as an upload error.
if err := s.linkAfterUpload(ctx, saved); err != nil {
s.logger.Warn("failed to link new picture", "imageId", saved.ID, "error", err)
}
return saved, result.Note, nil
}
// Reanalyze runs the analysis pipeline again over a stored picture. This is how
// badges are recovered after a failure or refreshed after the analyzer changes.
func (s *Service) Reanalyze(ctx context.Context, imageID string) (domain.Image, error) {
img, err := s.store.GetImage(ctx, imageID)
if err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
if img.StorageKey == "" {
return domain.Image{}, fmt.Errorf("%w: the picture has no stored bytes", ErrInvalidInput)
}
rc, err := s.blobs.Get(ctx, img.StorageKey)
if err != nil {
return domain.Image{}, fmt.Errorf("service: read stored picture: %w", err)
}
defer rc.Close()
// Bound the read by the configured limit even though the blob was written
// under it, so a corrupted store cannot exhaust memory.
var buf bytes.Buffer
if _, err := io.Copy(&buf, io.LimitReader(rc, s.cfg.MaxUploadBytes)); err != nil {
return domain.Image{}, fmt.Errorf("service: buffer stored picture: %w", err)
}
result, err := s.analyze.Analyze(ctx, analysis.Input{
ContentType: img.ContentType,
Bytes: buf.Bytes(),
})
if err != nil {
if setErr := s.store.SetAnalysisState(ctx, img.ID, domain.AnalysisFailed, err.Error()); setErr != nil {
s.logger.Warn("failed to record analysis failure", "imageId", img.ID, "error", setErr)
}
return domain.Image{}, fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
if err := s.store.ReplaceBadges(ctx, img.ID, result.Badges); err != nil {
return domain.Image{}, mapStoreError(err, "badge")
}
status := domain.AnalysisCompleted
if result.Note != "" {
// Understood, but only partially, which is worth recording so the UI
// can say the badge set may be incomplete.
status = domain.AnalysisFailed
}
if err := s.store.SetAnalysisState(ctx, img.ID, status, result.Note); err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
updated, err := s.store.GetImage(ctx, img.ID)
if err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
updated.URL = s.ImageURL(updated.StorageKey)
return updated, nil
}
// OpenImage returns a reader over a picture's stored bytes for the media route.
func (s *Service) OpenImage(ctx context.Context, imageID string) (domain.Image, io.ReadCloser, error) {
img, err := s.store.GetImage(ctx, imageID)
if err != nil {
return domain.Image{}, nil, mapStoreError(err, "image")
}
rc, err := s.blobs.Get(ctx, img.StorageKey)
if err != nil {
return domain.Image{}, nil, fmt.Errorf("service: open picture: %w", err)
}
return img, rc, nil
}
// ImageByID loads one picture, with its public URL filled in.
func (s *Service) ImageByID(ctx context.Context, id string) (domain.Image, error) {
img, err := s.store.GetImage(ctx, id)
if err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
img.URL = s.ImageURL(img.StorageKey)
return img, nil
}
// ImageByStorageKey looks a picture up by its storage key, which is what the
// media route uses to recover the declared content type and size.
func (s *Service) ImageByStorageKey(ctx context.Context, key string) (domain.Image, error) {
img, err := s.store.GetImageByStorageKey(ctx, key)
if err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
img.URL = s.ImageURL(img.StorageKey)
return img, nil
}
// ContentTypeByStorageKey returns the content type a picture was stored as, or
// an empty string when the key is unknown.
func (s *Service) ContentTypeByStorageKey(ctx context.Context, key string) (string, error) {
img, err := s.store.GetImageByStorageKey(ctx, key)
if err != nil {
return "", mapStoreError(err, "image")
}
return img.ContentType, nil
}
// ImageSize returns the stored byte size for a storage key, or zero when the
// key is unknown.
func (s *Service) ImageSize(ctx context.Context, key string) (int64, error) {
img, err := s.store.GetImageByStorageKey(ctx, key)
if err != nil {
return 0, mapStoreError(err, "image")
}
return img.ByteSize, nil
}
// extensionFor picks a storage extension from the filename, falling back to one
// implied by the content type.
func extensionFor(filename, contentType string) string {
if ext := strings.ToLower(path.Ext(filename)); validExtension(ext) {
return ext
}
switch strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) {
case "image/jpeg":
return ".jpg"
case "image/png":
return ".png"
case "image/webp":
return ".webp"
case "image/gif":
return ".gif"
default:
return ".img"
}
}
// validExtension keeps a client-supplied extension from becoming a path
// fragment or an unexpected suffix.
func validExtension(ext string) bool {
switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
return true
default:
return false
}
}
// badgeLabels reduces a badge set to a distinct list of labels for the event
// payload. The local and remote analyzers can each contribute a label for the
// same subject, so duplicates are collapsed rather than published twice.
func badgeLabels(badges []domain.Badge) []string {
out := make([]string, 0, len(badges))
seen := make(map[string]struct{}, len(badges))
for _, b := range badges {
label := strings.TrimSpace(b.Label)
if label == "" {
continue
}
if _, dup := seen[label]; dup {
continue
}
seen[label] = struct{}{}
out = append(out, label)
}
return out
}
func isTooLarge(err error) bool {
return err != nil && strings.Contains(err.Error(), blobstore.ErrTooLarge.Error())
}

View file

@ -0,0 +1,286 @@
package service
import (
"context"
"sort"
"go-places-api/internal/domain"
"go-places-api/internal/events"
"go-places-api/internal/geo"
"go-places-api/internal/store"
)
// maxLinkedImagesPerImage bounds how many links a single picture is given, so
// a dense cluster of pictures taken from the same spot does not produce a
// quadratic blow-up in the response.
const maxLinkedImagesPerImage = 50
// LinkReasons explains why two pictures were linked.
const (
// ReasonSamePlace means both pictures belong to the same place record.
ReasonSamePlace = "same-place"
// ReasonWithinRadius means the pictures were taken within the place's
// linking radius of each other, even though they belong to different
// place records. This is what catches the same spot catalogued twice.
ReasonWithinRadius = "within-radius"
)
// linkAfterUpload links a freshly stored picture to the others taken at the
// same place. Failures are the caller's to log rather than surface, because the
// picture itself is already safely stored.
func (s *Service) linkAfterUpload(ctx context.Context, img domain.Image) error {
place, err := s.store.GetPlace(ctx, img.PlaceID)
if err != nil {
return mapStoreError(err, "place")
}
center := effectiveCoordinate(img, place.Coordinate)
if center == nil {
// Without a location there is nothing to compare against. This is
// common and not an error.
return nil
}
radius := place.LinkRadiusMeters
if radius <= 0 {
radius = s.cfg.DefaultLinkRadiusMeters
}
candidates, err := s.store.ImagesWithin(ctx, store.NearbyQuery{
Center: *center,
RadiusMeters: radius,
})
if err != nil {
return mapStoreError(err, "image")
}
links := LinksFor(img, candidates, place, radius)
if len(links) == 0 {
return nil
}
// Persist only the cross-place relationships, since a picture inside its own
// place is already known from the place record. Storing the evidence makes
// the link discoverable without re-running the spatial query.
for _, link := range links {
if link.SamePlace {
continue
}
if err := s.store.CreateImageLink(ctx, img.ID, link.RelatedImageID, link.DistanceMeters, link.Reason); err != nil {
s.logger.Warn("failed to record image link",
"imageId", img.ID, "relatedImageId", link.RelatedImageID, "error", err)
}
}
s.publish(ctx, domain.User{}, events.TopicImageLinked, map[string]any{
"imageId": img.ID,
"placeId": img.PlaceID,
"links": len(links),
"distance": nearestDistance(links),
})
return nil
}
// LinksFor computes the links for one picture against a candidate set. It is
// exported through the service so the GraphQL layer can reuse exactly the rules
// the upload path applies.
//
// A link is created when the two pictures are within radius. Pictures in the
// same place are linked unconditionally, since sharing a place record is itself
// the evidence; pictures in different places are linked only when their capture
// points are genuinely close.
func LinksFor(img domain.Image, candidates []domain.Image, place domain.Place, radius float64) []domain.Link {
if radius <= 0 {
return nil
}
center := effectiveCoordinate(img, place.Coordinate)
if center == nil {
return nil
}
var links []domain.Link
for _, other := range candidates {
if other.ID == img.ID {
continue
}
samePlace := other.PlaceID == img.PlaceID
if samePlace {
links = append(links, domain.Link{
ImageID: img.ID,
RelatedImageID: other.ID,
DistanceMeters: 0,
SamePlace: true,
Reason: ReasonSamePlace,
})
continue
}
otherCenter := effectiveCoordinate(other, nil)
if otherCenter == nil {
continue
}
distance := geo.DistanceMeters(*center, *otherCenter)
if distance > radius {
continue
}
links = append(links, domain.Link{
ImageID: img.ID,
RelatedImageID: other.ID,
DistanceMeters: geo.Round(distance),
SamePlace: false,
Reason: ReasonWithinRadius,
})
}
// Nearest first, then by identifier so the ordering is stable.
sort.SliceStable(links, func(i, j int) bool {
if links[i].DistanceMeters != links[j].DistanceMeters {
return links[i].DistanceMeters < links[j].DistanceMeters
}
return links[i].RelatedImageID < links[j].RelatedImageID
})
if len(links) > maxLinkedImagesPerImage {
links = links[:maxLinkedImagesPerImage]
}
return links
}
// LinkedImages returns the other pictures taken at the same place as img.
//
// Pictures within the same place record are always included. Pictures from
// other places are included when their recorded capture point lies inside the
// radius, which is what surfaces pictures catalogued under separate places at
// the same coordinates.
func (s *Service) LinkedImages(ctx context.Context, img domain.Image) ([]domain.Image, error) {
place, err := s.store.GetPlace(ctx, img.PlaceID)
if err != nil {
return nil, mapStoreError(err, "place")
}
center := effectiveCoordinate(img, place.Coordinate)
radius := place.LinkRadiusMeters
if radius <= 0 {
radius = s.cfg.DefaultLinkRadiusMeters
}
if center == nil || radius <= 0 {
// Without a coordinate there is nothing to search with, but the
// pictures sharing this place are still linked by definition.
return siblingsOnly(img, place)
}
seen := map[string]bool{img.ID: true}
out := make([]domain.Image, 0, 8)
for _, sibling := range place.Images {
if seen[sibling.ID] {
continue
}
seen[sibling.ID] = true
out = append(out, decorate(sibling, s.ImageURL(sibling.StorageKey)))
}
candidates, err := s.store.ImagesWithin(ctx, store.NearbyQuery{
Center: *center,
RadiusMeters: radius,
})
if err != nil {
return nil, mapStoreError(err, "image")
}
for _, other := range candidates {
if seen[other.ID] {
continue
}
otherCenter := effectiveCoordinate(other, nil)
if otherCenter == nil || !geo.WithinRadius(*center, *otherCenter, radius) {
continue
}
seen[other.ID] = true
out = append(out, decorate(other, s.ImageURL(other.StorageKey)))
if len(out) >= maxLinkedImagesPerImage {
break
}
}
return out, nil
}
// siblingsOnly returns the other pictures in the same place, used when no
// coordinate is available to search with.
func siblingsOnly(img domain.Image, place domain.Place) ([]domain.Image, error) {
out := make([]domain.Image, 0, len(place.Images))
for _, sibling := range place.Images {
if sibling.ID == img.ID {
continue
}
out = append(out, sibling)
}
return out, nil
}
// LinksForImage returns the computed links for one picture, which is what the
// GraphQL "links" field exposes.
func (s *Service) LinksForImage(ctx context.Context, imageID string) ([]domain.Link, error) {
img, err := s.store.GetImage(ctx, imageID)
if err != nil {
return nil, mapStoreError(err, "image")
}
place, err := s.store.GetPlace(ctx, img.PlaceID)
if err != nil {
return nil, mapStoreError(err, "place")
}
center := effectiveCoordinate(img, place.Coordinate)
radius := place.LinkRadiusMeters
if radius <= 0 {
radius = s.cfg.DefaultLinkRadiusMeters
}
if center == nil {
links := make([]domain.Link, 0, len(place.Images))
for _, sibling := range place.Images {
if sibling.ID == img.ID {
continue
}
links = append(links, domain.Link{
ImageID: img.ID,
RelatedImageID: sibling.ID,
SamePlace: true,
Reason: ReasonSamePlace,
})
}
return links, nil
}
candidates, err := s.store.ImagesWithin(ctx, store.NearbyQuery{
Center: *center,
RadiusMeters: radius,
})
if err != nil {
return nil, mapStoreError(err, "image")
}
return LinksFor(img, candidates, place, radius), nil
}
// ImageLinks reads back the links recorded for a picture.
func (s *Service) ImageLinks(ctx context.Context, imageID string) ([]domain.Link, error) {
links, err := s.store.ListImageLinks(ctx, imageID)
if err != nil {
return nil, mapStoreError(err, "image link")
}
return links, nil
}
func nearestDistance(links []domain.Link) float64 {
nearest := 0.0
for i, l := range links {
if i == 0 || l.DistanceMeters < nearest {
nearest = l.DistanceMeters
}
}
return nearest
}
// decorate copies an image and fills in the public URL, which is derived from
// the storage key and therefore not stored.
func decorate(img domain.Image, url string) domain.Image {
img.URL = url
return img
}

View file

@ -0,0 +1,392 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"go-places-api/internal/domain"
"go-places-api/internal/events"
"go-places-api/internal/geo"
"go-places-api/internal/store"
)
// maxTitleLength and maxDescriptionLength bound what a client can store, which
// keeps a single request from writing an unbounded blob of text.
const (
maxTitleLength = 200
maxDescriptionLength = 5000
maxCommentLength = 2000
)
// CreatePlace stores a new place and publishes an event for it.
func (s *Service) CreatePlace(ctx context.Context, author domain.User, in domain.PlaceInput) (domain.Place, error) {
in.Title = strings.TrimSpace(in.Title)
in.Description = strings.TrimSpace(in.Description)
if in.Title == "" {
return domain.Place{}, fmt.Errorf("%w: title is required", ErrInvalidInput)
}
if len(in.Title) > maxTitleLength {
return domain.Place{}, fmt.Errorf("%w: title exceeds %d characters", ErrInvalidInput, maxTitleLength)
}
if len(in.Description) > maxDescriptionLength {
return domain.Place{}, fmt.Errorf("%w: description exceeds %d characters", ErrInvalidInput, maxDescriptionLength)
}
radius := s.cfg.DefaultLinkRadiusMeters
if in.LinkRadiusMeters != nil {
radius = *in.LinkRadiusMeters
}
if radius <= 0 {
return domain.Place{}, fmt.Errorf("%w: link radius must be positive", ErrInvalidInput)
}
if radius > s.cfg.MaxLinkRadiusMeters {
return domain.Place{}, fmt.Errorf("%w: link radius must not exceed %g metres",
ErrInvalidInput, s.cfg.MaxLinkRadiusMeters)
}
place := domain.Place{
Title: in.Title,
Description: in.Description,
Coordinate: in.Coordinate(),
LinkRadiusMeters: radius,
Creator: author,
}
if err := s.store.UpsertUser(ctx, author); err != nil {
return domain.Place{}, mapStoreError(err, "user")
}
created, err := s.store.CreatePlace(ctx, place)
if err != nil {
return domain.Place{}, mapStoreError(err, "place")
}
s.publish(ctx, author, events.TopicPlaceCreated, map[string]any{
"placeId": created.ID,
"title": created.Title,
"creator": author.ID,
})
return created, nil
}
// GetPlace returns one place with its pictures and comments.
//
// The pictures' public URLs are filled in here because the URL is derived from
// the storage key rather than stored, and a caller rendering this place should
// not have to know that.
func (s *Service) GetPlace(ctx context.Context, id string) (domain.Place, error) {
if strings.TrimSpace(id) == "" {
return domain.Place{}, fmt.Errorf("%w: place id is required", ErrInvalidInput)
}
place, err := s.store.GetPlace(ctx, id)
if err != nil {
return domain.Place{}, mapStoreError(err, "place")
}
place.Images = s.withImageURLs(place.Images)
// The collections are serialised to JSON, where a nil slice becomes null.
// Callers iterate them directly, so an empty result is normalised to an
// empty slice here rather than at each read site.
if place.Images == nil {
place.Images = []domain.Image{}
}
if place.Comments == nil {
place.Comments = []domain.Comment{}
}
if place.RelatedPlaces == nil {
place.RelatedPlaces = []domain.RelatedPlace{}
}
return place, nil
}
// withImageURLs returns a copy of images with each public URL filled in.
func (s *Service) withImageURLs(images []domain.Image) []domain.Image {
if len(images) == 0 {
return images
}
out := make([]domain.Image, 0, len(images))
for _, img := range images {
out = append(out, decorate(img, s.ImageURL(img.StorageKey)))
}
return out
}
// ListPlaces returns places newest first. The listing is a summary, so its
// pictures and comments are left empty; a client that needs them reads the place
// individually.
func (s *Service) ListPlaces(ctx context.Context, limit, offset int) ([]domain.Place, error) {
limit, offset = clampPage(limit, offset)
places, err := s.store.ListPlaces(ctx, limit, offset)
if err != nil {
return nil, mapStoreError(err, "place")
}
// An empty result is returned as an empty slice rather than nil, so it
// serialises as [] and clients can iterate without a null check.
if places == nil {
return []domain.Place{}, nil
}
for i := range places {
places[i].Images = []domain.Image{}
places[i].Comments = []domain.Comment{}
places[i].RelatedPlaces = []domain.RelatedPlace{}
}
return places, nil
}
// DeletePlace removes a place together with its pictures, deleting their blobs
// so storage does not leak.
func (s *Service) DeletePlace(ctx context.Context, id string) error {
place, err := s.store.GetPlace(ctx, id)
if err != nil {
return mapStoreError(err, "place")
}
if err := s.store.DeletePlace(ctx, id); err != nil {
return mapStoreError(err, "place")
}
for _, img := range place.Images {
if img.StorageKey == "" {
continue
}
if err := s.blobs.Delete(ctx, img.StorageKey); err != nil {
// The row is already gone; leaking the blob is worth a warning but
// not worth failing the request.
s.logger.Warn("failed to delete image blob", "imageId", img.ID, "key", img.StorageKey, "error", err)
}
}
return nil
}
// AddComment posts a comment on a place.
func (s *Service) AddComment(ctx context.Context, author domain.User, placeID, content string) (domain.Comment, error) {
content = strings.TrimSpace(content)
if placeID == "" {
return domain.Comment{}, fmt.Errorf("%w: place id is required", ErrInvalidInput)
}
if content == "" {
return domain.Comment{}, fmt.Errorf("%w: comment content is required", ErrInvalidInput)
}
if len(content) > maxCommentLength {
return domain.Comment{}, fmt.Errorf("%w: comment exceeds %d characters", ErrInvalidInput, maxCommentLength)
}
// Confirm the place exists so a comment cannot dangle.
if _, err := s.store.GetPlace(ctx, placeID); err != nil {
return domain.Comment{}, mapStoreError(err, "place")
}
if err := s.store.UpsertUser(ctx, author); err != nil {
return domain.Comment{}, mapStoreError(err, "user")
}
comment, err := s.store.AddComment(ctx, domain.Comment{
PlaceID: placeID,
Content: content,
Creator: author,
})
if err != nil {
return domain.Comment{}, mapStoreError(err, "comment")
}
// Find the place owner so the notification carries a real recipient rather
// than the placeholder the original scaffold used.
recipient := ""
if place, err := s.store.GetPlace(ctx, placeID); err == nil {
recipient = place.Creator.ID
}
s.publish(ctx, author, events.TopicCommentAdded, map[string]any{
"commentId": comment.ID,
"placeId": placeID,
"commenterId": author.ID,
"placeOwnerId": recipient,
"notifyOwner": recipient != "" && recipient != author.ID,
})
return comment, nil
}
// RelatedPlaces finds other places whose pictures overlap the given one, which
// is the place-level view of the same-place linking.
func (s *Service) RelatedPlaces(ctx context.Context, placeID string) ([]domain.RelatedPlace, error) {
place, err := s.store.GetPlace(ctx, placeID)
if err != nil {
return nil, mapStoreError(err, "place")
}
if len(place.Images) == 0 {
return nil, nil
}
radius := place.LinkRadiusMeters
if radius <= 0 {
radius = s.cfg.DefaultLinkRadiusMeters
}
// Anchor the search on each picture's own coordinates when it has them,
// otherwise on the place coordinate.
var (
related = map[string]*domain.RelatedPlace{}
ownImages = map[string]bool{}
)
for _, img := range place.Images {
ownImages[img.ID] = true
}
for _, img := range place.Images {
center := effectiveCoordinate(img, place.Coordinate)
if center == nil {
continue
}
neighbours, err := s.store.ImagesWithin(ctx, store.NearbyQuery{
Center: *center,
RadiusMeters: radius,
})
if err != nil {
return nil, mapStoreError(err, "image")
}
for _, other := range neighbours {
otherCenter := effectiveCoordinate(other, nil)
if otherCenter == nil {
continue
}
distance := geo.DistanceMeters(*center, *otherCenter)
if !geo.WithinRadius(*center, *otherCenter, radius) {
continue
}
if ownImages[other.ID] || other.ID == img.ID {
continue
}
entry, ok := related[other.PlaceID]
if !ok {
entry = &domain.RelatedPlace{DistanceMeters: distance}
related[other.PlaceID] = entry
}
if distance < entry.DistanceMeters {
entry.DistanceMeters = distance
}
entry.SharedImageIDs = append(entry.SharedImageIDs, other.ID)
}
}
// Load the full related places so the caller receives usable records.
out := make([]domain.RelatedPlace, 0, len(related))
for relPlaceID, rel := range related {
loaded, err := s.GetPlace(ctx, relPlaceID)
if err != nil {
s.logger.Warn("failed to load related place", "placeId", relPlaceID, "error", err)
continue
}
// A related place listing its own related places would recurse without
// end, so the nested copy carries pictures but no further relations.
loaded.RelatedPlaces = nil
rel.Place = loaded
out = append(out, *rel)
}
sortRelated(out)
return out, nil
}
// UpdateImageCaption changes the description attached to a picture.
func (s *Service) UpdateImageCaption(ctx context.Context, imageID, caption string) (domain.Image, error) {
caption = strings.TrimSpace(caption)
if len(caption) > maxDescriptionLength {
return domain.Image{}, fmt.Errorf("%w: caption exceeds %d characters", ErrInvalidInput, maxDescriptionLength)
}
img, err := s.store.GetImage(ctx, imageID)
if err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
img.Caption = caption
updated, err := s.store.UpdateImage(ctx, img)
if err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
return updated, nil
}
// ListImages returns pictures matching a filter, with each public URL filled in.
func (s *Service) ListImages(ctx context.Context, f store.ImageFilter) ([]domain.Image, error) {
f.Limit, f.Offset = clampPage(f.Limit, f.Offset)
images, err := s.store.ListImages(ctx, f)
if err != nil {
return nil, mapStoreError(err, "image")
}
return s.withImageURLs(images), nil
}
// DeleteImage removes a picture and its stored bytes.
func (s *Service) DeleteImage(ctx context.Context, id string) error {
img, err := s.store.GetImage(ctx, id)
if err != nil {
return mapStoreError(err, "image")
}
if err := s.store.DeleteImage(ctx, id); err != nil {
return mapStoreError(err, "image")
}
if img.StorageKey != "" {
if err := s.blobs.Delete(ctx, img.StorageKey); err != nil {
s.logger.Warn("failed to delete image blob", "imageId", id, "error", err)
}
}
return nil
}
// ImageURL builds the public URL for a stored picture.
func (s *Service) ImageURL(storageKey string) string {
if storageKey == "" {
return ""
}
if s.cfg.PublicBaseURL != "" {
return strings.TrimRight(s.cfg.PublicBaseURL, "/") + "/media/" + storageKey
}
return "/media/" + storageKey
}
// publish sends an event, treating delivery as best effort so a broker problem
// never fails the request that produced it.
func (s *Service) publish(ctx context.Context, actor domain.User, topic events.Topic, payload map[string]any) {
body, err := marshalPayload(payload)
if err != nil {
s.logger.Warn("failed to encode event", "topic", string(topic), "error", err)
return
}
ev := events.Event{
ID: uuid.NewString(),
Topic: topic,
OccurredAt: time.Now().UTC(),
ActorID: actor.ID,
Payload: body,
}
if err := s.events.Publish(ctx, ev); err != nil {
s.logger.Warn("failed to publish event", "topic", string(topic), "error", err)
}
}
// effectiveCoordinate returns the picture's own capture point when it has one,
// falling back to the place it belongs to.
func effectiveCoordinate(img domain.Image, place *domain.Coordinate) *domain.Coordinate {
if img.Coordinate != nil && img.Coordinate.Valid() {
return img.Coordinate
}
if place != nil && place.Valid() {
return place
}
return nil
}
func clampPage(limit, offset int) (int, int) {
if limit < 0 {
limit = 0
}
if offset < 0 {
offset = 0
}
return limit, offset
}
var errNoCoordinate = errors.New("no coordinate")

View file

@ -0,0 +1,113 @@
// Package service holds the business logic. It coordinates the store, the blob
// store, the analyzer and the event publisher, and it is the only layer that
// knows how the application's features fit together.
package service
import (
"context"
"errors"
"fmt"
"go-places-api/internal/analysis"
"go-places-api/internal/blobstore"
"go-places-api/internal/config"
"go-places-api/internal/events"
"go-places-api/internal/store"
)
// Errors surfaced to the transport layer. The HTTP and GraphQL layers map these
// onto status codes and GraphQL error extensions.
var (
// ErrNotFound means the addressed entity does not exist.
ErrNotFound = errors.New("not found")
// ErrInvalidInput means the request was malformed or violated a rule.
ErrInvalidInput = errors.New("invalid input")
// ErrTooLarge means the upload exceeded the configured limit.
ErrTooLarge = errors.New("upload too large")
// ErrUnsupportedType means the upload's content type is not allowed.
ErrUnsupportedType = errors.New("unsupported media type")
)
// Service is the application entry point used by the transport layers.
type Service struct {
store store.Store
blobs blobstore.BlobStore
analyze analysis.Analyzer
events events.Publisher
cfg config.Config
logger Logger
}
// Logger is the small logging surface the service needs. Keeping it narrow
// means tests can pass a no-op without pulling in slog.
type Logger interface {
Warn(msg string, args ...any)
Info(msg string, args ...any)
}
// Options configures a Service.
type Options struct {
Store store.Store
Blobs blobstore.BlobStore
Analyzer analysis.Analyzer
Events events.Publisher
Config config.Config
Logger Logger
}
// New builds a Service, filling in no-op collaborators where none are supplied
// so a partially wired service still works in tests.
func New(opts Options) *Service {
s := &Service{
store: opts.Store,
blobs: opts.Blobs,
analyze: opts.Analyzer,
events: opts.Events,
cfg: opts.Config,
logger: opts.Logger,
}
if s.analyze == nil {
s.analyze = analysis.NewLocal(analysis.DefaultMaxDimension)
}
if s.events == nil {
s.events = events.NewLogPublisher(nil)
}
if s.logger == nil {
s.logger = nopLogger{}
}
return s
}
// Pinger is implemented by stores that can report whether they are reachable.
// It is checked by the readiness probe, and is optional so an in-memory fake
// still satisfies the contract without implementing it.
type Pinger interface {
PingContext(ctx context.Context) error
}
// Ping reports whether the service's dependencies can serve traffic. A store
// that cannot report its health is treated as healthy.
func (s *Service) Ping(ctx context.Context) error {
p, ok := s.store.(Pinger)
if !ok {
return nil
}
return p.PingContext(ctx)
}
// mapStoreError translates store sentinels into service sentinels.
func mapStoreError(err error, entity string) error {
switch {
case errors.Is(err, store.ErrNotFound):
return fmt.Errorf("%w: no such %s", ErrNotFound, entity)
case errors.Is(err, store.ErrConflict):
return fmt.Errorf("%w: conflicting %s", ErrInvalidInput, entity)
default:
return err
}
}
type nopLogger struct{}
func (nopLogger) Warn(string, ...any) {}
func (nopLogger) Info(string, ...any) {}

View 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