initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
29
api/internal/service/helpers.go
Normal file
29
api/internal/service/helpers.go
Normal 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
|
||||
})
|
||||
}
|
||||
357
api/internal/service/image.go
Normal file
357
api/internal/service/image.go
Normal 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())
|
||||
}
|
||||
286
api/internal/service/link.go
Normal file
286
api/internal/service/link.go
Normal 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
|
||||
}
|
||||
392
api/internal/service/place.go
Normal file
392
api/internal/service/place.go
Normal 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")
|
||||
113
api/internal/service/service.go
Normal file
113
api/internal/service/service.go
Normal 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) {}
|
||||
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