Behavision: face recognition for retail, edge to head office
Five components that ship as one product:
- behavision/ the recognition engine. RTSP ingest, YuNet detection, IoU
tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
FastAPI dashboard. Identity is decided once per TRACK from an
average of at least three embeddings, never per frame.
- agent/ the Go edge agent: supervises the engine, holds a durable
spool, and drains it to MQTT. Nothing is acked before the
broker confirms.
- desktop/ the shop PC application (Wails + React + tray).
- server/ the cloud API, MQTT consumer, reports and assistant.
- web/ platform.loyaly.ai, the head-office app, embedded in the
server binary.
The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.
CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
588
server/internal/api/fake_test.go
Normal file
588
server/internal/api/fake_test.go
Normal file
@@ -0,0 +1,588 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
// fakeStore is an in-memory Store. Handlers are where the security decisions
|
||||
// live - which tenant, which message on failure, what is echoed back - and
|
||||
// those are exactly what a real database would make slow and awkward to test.
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
|
||||
users map[string]UserRecord // by lower-cased email
|
||||
sessions map[string]*fakeSession
|
||||
byAccess map[string]string // access hash hex -> session id
|
||||
byRefresh map[string]string
|
||||
|
||||
visitors []Customer
|
||||
history []VisitRow
|
||||
footfall []FootfallPoint
|
||||
totals Totals
|
||||
sales SalesReport
|
||||
sites []SiteHealth
|
||||
enrolment map[string]Enrolment
|
||||
|
||||
// Recorded calls, so a test can assert what the handler asked for rather
|
||||
// than only what it returned.
|
||||
lastReport ReportQuery
|
||||
lastProfile Profile
|
||||
lastProfileClient string
|
||||
lastPurchase PurchaseInput
|
||||
audits []AuditEntry
|
||||
|
||||
// arrivals is the whole table; arrivalQ records what the handler asked for
|
||||
// so a test can assert on the keyset window rather than only its output.
|
||||
arrivals []Arrival
|
||||
arrivalQ ArrivalQuery
|
||||
arrivalsErr error
|
||||
arrivalCalls int
|
||||
|
||||
pendingChecks []AgentCheckJob
|
||||
checkResults []AgentCheckResult
|
||||
releasedStale int
|
||||
lastCodeActor string
|
||||
lastCodeTTL time.Duration
|
||||
lastCheckKind string
|
||||
lastCheckSeconds int
|
||||
|
||||
cameras []Camera
|
||||
agentCameras []AgentCamera
|
||||
lastCameraReport AgentCameraReport
|
||||
lastReportClient string
|
||||
lastReportSite string
|
||||
saveCameraErr error
|
||||
lastSaved CameraInput
|
||||
|
||||
clients []ClientRow
|
||||
lastNewClient NewClientInput
|
||||
newClientErr error
|
||||
loginTouched []string
|
||||
|
||||
profileErr error
|
||||
purchaseErr error
|
||||
forgetErr error
|
||||
nextID int
|
||||
|
||||
agentTokens map[string]AgentPrincipal
|
||||
imageKeys map[string]string
|
||||
forgotten []string
|
||||
}
|
||||
|
||||
type fakeSession struct {
|
||||
id string
|
||||
p auth.Principal
|
||||
accessExp, refreshExp time.Time
|
||||
revoked bool
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{
|
||||
users: map[string]UserRecord{},
|
||||
sessions: map[string]*fakeSession{},
|
||||
byAccess: map[string]string{},
|
||||
byRefresh: map[string]string{},
|
||||
enrolment: map[string]Enrolment{},
|
||||
agentTokens: map[string]AgentPrincipal{},
|
||||
imageKeys: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeStore) addUser(email, password string, rec UserRecord) {
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
rec.Email = email
|
||||
rec.PasswordHash = hash
|
||||
rec.Found = true
|
||||
if rec.ID == "" {
|
||||
rec.ID = "user-" + email
|
||||
}
|
||||
if rec.Role == "" {
|
||||
rec.Role = "manager"
|
||||
}
|
||||
f.users[auth.NormalizeEmail(email)] = rec
|
||||
}
|
||||
|
||||
func (f *fakeStore) UserByEmail(_ context.Context, email string) (UserRecord, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
u, ok := f.users[email]
|
||||
if !ok {
|
||||
return UserRecord{Found: false}, nil
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) TouchUserLogin(_ context.Context, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.loginTouched = append(f.loginTouched, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateSession(_ context.Context, n NewSession) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.nextID++
|
||||
id := "sess-" + itoa(f.nextID)
|
||||
var rec UserRecord
|
||||
for _, u := range f.users {
|
||||
if u.ID == n.UserID {
|
||||
rec = u
|
||||
}
|
||||
}
|
||||
s := &fakeSession{
|
||||
id: id,
|
||||
p: auth.Principal{
|
||||
UserID: n.UserID, SessionID: id, ClientID: n.ClientID,
|
||||
ClientName: rec.ClientName, Email: rec.Email,
|
||||
FullName: rec.FullName, Role: rec.Role,
|
||||
},
|
||||
accessExp: n.AccessExpiry, refreshExp: n.RefreshExp,
|
||||
}
|
||||
f.sessions[id] = s
|
||||
f.byAccess[hex.EncodeToString(n.AccessHash)] = id
|
||||
f.byRefresh[hex.EncodeToString(n.RefreshHash)] = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) lookup(index map[string]string, hash []byte, refresh bool) (
|
||||
auth.Principal, time.Time, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
id, ok := index[hex.EncodeToString(hash)]
|
||||
if !ok {
|
||||
return auth.Principal{}, time.Time{}, auth.ErrNoSession
|
||||
}
|
||||
s := f.sessions[id]
|
||||
if s == nil || s.revoked {
|
||||
return auth.Principal{}, time.Time{}, auth.ErrNoSession
|
||||
}
|
||||
if refresh {
|
||||
return s.p, s.refreshExp, nil
|
||||
}
|
||||
return s.p, s.accessExp, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SessionByAccess(_ context.Context, h []byte) (auth.Principal, time.Time, error) {
|
||||
return f.lookup(f.byAccess, h, false)
|
||||
}
|
||||
|
||||
func (f *fakeStore) SessionByRefresh(_ context.Context, h []byte) (auth.Principal, time.Time, error) {
|
||||
return f.lookup(f.byRefresh, h, true)
|
||||
}
|
||||
|
||||
func (f *fakeStore) RotateSession(_ context.Context, id string, n NewSession) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
s := f.sessions[id]
|
||||
if s == nil || s.revoked {
|
||||
return auth.ErrNoSession
|
||||
}
|
||||
// Mirrors the real store: the old hashes stop resolving the moment the new
|
||||
// ones are written.
|
||||
for k, v := range f.byAccess {
|
||||
if v == id {
|
||||
delete(f.byAccess, k)
|
||||
}
|
||||
}
|
||||
for k, v := range f.byRefresh {
|
||||
if v == id {
|
||||
delete(f.byRefresh, k)
|
||||
}
|
||||
}
|
||||
f.byAccess[hex.EncodeToString(n.AccessHash)] = id
|
||||
f.byRefresh[hex.EncodeToString(n.RefreshHash)] = id
|
||||
s.accessExp, s.refreshExp = n.AccessExpiry, n.RefreshExp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RevokeSession(_ context.Context, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if s := f.sessions[id]; s != nil {
|
||||
s.revoked = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Footfall(_ context.Context, q ReportQuery) ([]FootfallPoint, Totals, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastReport = q
|
||||
return f.footfall, f.totals, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Conversion(_ context.Context, q ReportQuery) (SalesReport, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastReport = q
|
||||
return f.sales, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SiteHealth(_ context.Context, _ string) ([]SiteHealth, error) {
|
||||
return f.sites, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SearchVisitors(_ context.Context, clientID, q string, limit int) (
|
||||
[]Customer, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastReport = ReportQuery{ClientID: clientID}
|
||||
if limit < len(f.visitors) {
|
||||
return f.visitors[:limit], nil
|
||||
}
|
||||
return f.visitors, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) VisitorHistory(_ context.Context, _, _ string, _ int) ([]VisitRow, error) {
|
||||
return f.history, nil
|
||||
}
|
||||
|
||||
// Arrivals fakes the keyset window in memory: rows are held oldest-first, a
|
||||
// cursor slices past it, and no cursor returns the newest Limit - the same
|
||||
// contract the SQL implements, so a handler test that passes here is testing
|
||||
// the handler and not a stub that is easier than the real thing.
|
||||
func (f *fakeStore) Arrivals(_ context.Context, q ArrivalQuery) ([]Arrival, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.arrivalQ = q
|
||||
f.arrivalCalls++
|
||||
if f.arrivalsErr != nil {
|
||||
return nil, f.arrivalsErr
|
||||
}
|
||||
rows := make([]Arrival, 0, len(f.arrivals))
|
||||
for _, a := range f.arrivals {
|
||||
if q.SiteID != "" && a.SiteID != q.SiteID {
|
||||
continue
|
||||
}
|
||||
if q.AfterSeq != nil && a.Seq <= *q.AfterSeq {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, a)
|
||||
}
|
||||
if q.AfterSeq == nil && len(rows) > q.Limit {
|
||||
// No cursor: the newest window, matching the real query.
|
||||
rows = rows[len(rows)-q.Limit:]
|
||||
} else if len(rows) > q.Limit {
|
||||
rows = rows[:q.Limit]
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SaveProfile(_ context.Context, clientID string, p Profile, _ string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastProfile, f.lastProfileClient = p, clientID
|
||||
return f.profileErr
|
||||
}
|
||||
|
||||
func (f *fakeStore) RecordPurchase(_ context.Context, _ string, p PurchaseInput, _ string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastPurchase = p
|
||||
return f.purchaseErr
|
||||
}
|
||||
|
||||
func (f *fakeStore) RedeemEnrolment(_ context.Context, hash []byte) (Enrolment, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
en, ok := f.enrolment[hex.EncodeToString(hash)]
|
||||
if !ok {
|
||||
return Enrolment{}, errors.New("unknown token")
|
||||
}
|
||||
// Single use, like the real UPDATE.
|
||||
delete(f.enrolment, hex.EncodeToString(hash))
|
||||
return en, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Cameras(_ context.Context, _, siteID string) ([]Camera, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []Camera
|
||||
for _, c := range f.cameras {
|
||||
if siteID == "" || c.SiteID == siteID {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CameraByID(_ context.Context, _, id string) (Camera, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, c := range f.cameras {
|
||||
if c.ID == id {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return Camera{}, errors.New("no rows in result set")
|
||||
}
|
||||
|
||||
func (f *fakeStore) SaveCamera(_ context.Context, _, siteID, cameraID string,
|
||||
in CameraInput) (Camera, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastSaved = in
|
||||
if f.saveCameraErr != nil {
|
||||
return Camera{}, f.saveCameraErr
|
||||
}
|
||||
// A real-shaped uuid: the handlers check the shape before touching SQL, so
|
||||
// a placeholder id would exercise the 404 path instead of the one under
|
||||
// test.
|
||||
cam := Camera{ID: fakeCameraUUID(cameraID), SiteID: siteID, CameraID: cameraID,
|
||||
Port: 554, Path: "/", MaxWidth: 1280, Enabled: true, Revision: 1}
|
||||
if in.Label != nil {
|
||||
cam.Label = *in.Label
|
||||
}
|
||||
if in.Host != nil {
|
||||
cam.Host = *in.Host
|
||||
}
|
||||
if in.Username != nil {
|
||||
cam.Username = *in.Username
|
||||
}
|
||||
cam.HasPassword = in.Password != nil && *in.Password != ""
|
||||
f.cameras = append(f.cameras, cam)
|
||||
return cam, nil
|
||||
}
|
||||
|
||||
// fakeCameraUUID derives a stable uuid-shaped id from a camera name so tests
|
||||
// can address a camera they just created without reading the response.
|
||||
func fakeCameraUUID(cameraID string) string {
|
||||
sum := sha256.Sum256([]byte(cameraID))
|
||||
h := hex.EncodeToString(sum[:16])
|
||||
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteCamera(_ context.Context, _, id string) (Camera, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i, c := range f.cameras {
|
||||
if c.ID == id {
|
||||
f.cameras = append(f.cameras[:i], f.cameras[i+1:]...)
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return Camera{}, errors.New("no rows in result set")
|
||||
}
|
||||
|
||||
func (f *fakeStore) AgentCameras(_ context.Context, _ string) ([]AgentCamera, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.agentCameras, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ApplyAgentReport(_ context.Context, clientID, siteID string,
|
||||
rep AgentCameraReport) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastCameraReport = rep
|
||||
f.lastReportClient, f.lastReportSite = clientID, siteID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) IssueEnrolmentCode(_ context.Context, clientID, siteID,
|
||||
actorID, label string, ttl time.Duration) (EnrolmentCode, error) {
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, si := range f.sites {
|
||||
if si.SiteID == siteID {
|
||||
f.lastCodeActor, f.lastCodeTTL = actorID, ttl
|
||||
return EnrolmentCode{
|
||||
Code: "ABCDEF-123456-GHIJKL-789012", SiteID: siteID,
|
||||
SiteName: si.Name, Label: label,
|
||||
ExpiresAt: time.Now().Add(ttl).UTC(),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return EnrolmentCode{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (f *fakeStore) RequestCheck(_ context.Context, _, id, kind string, seconds int) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i := range f.cameras {
|
||||
if f.cameras[i].ID == id {
|
||||
f.cameras[i].Check = CameraCheck{Kind: kind, Seconds: seconds, State: "requested"}
|
||||
f.lastCheckKind, f.lastCheckSeconds = kind, seconds
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("no rows in result set")
|
||||
}
|
||||
|
||||
func (f *fakeStore) ClaimChecks(_ context.Context, _ string) ([]AgentCheckJob, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := f.pendingChecks
|
||||
f.pendingChecks = nil // claimed once, like the real UPDATE ... RETURNING
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RecordCheckResult(_ context.Context, _ string, res AgentCheckResult) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.checkResults = append(f.checkResults, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ReleaseStaleChecks(_ context.Context, _ time.Duration) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.releasedStale++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListClients(_ context.Context) ([]ClientRow, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.clients, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateClientWithOwner(_ context.Context, in NewClientInput) (
|
||||
NewClientResult, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastNewClient = in
|
||||
if f.newClientErr != nil {
|
||||
return NewClientResult{}, f.newClientErr
|
||||
}
|
||||
pw := in.Password
|
||||
if pw == "" {
|
||||
pw = "generated-password"
|
||||
}
|
||||
return NewClientResult{ClientID: "new-client-id", Slug: in.Slug,
|
||||
OwnerEmail: in.OwnerEmail, Password: pw}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Audit(_ context.Context, e AuditEntry) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.audits = append(f.audits, e)
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for n > 0 {
|
||||
b = append([]byte{byte('0' + n%10)}, b...)
|
||||
n /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// -- images and agents ------------------------------------------------------
|
||||
|
||||
func (f *fakeStore) SetAgentAPIToken(_ context.Context, agentID string, hash []byte) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.agentTokens == nil {
|
||||
f.agentTokens = map[string]AgentPrincipal{}
|
||||
}
|
||||
f.agentTokens[hex.EncodeToString(hash)] = AgentPrincipal{
|
||||
AgentID: agentID, ClientID: "client-acme", SiteID: "site-1",
|
||||
Slug: "acme.store1", Client: "acme", Site: "store1",
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addAgent registers a plaintext agent token, hashed the way the middleware
|
||||
// will look it up.
|
||||
func (f *fakeStore) addAgent(token string, ap AgentPrincipal) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.agentTokens[hex.EncodeToString(auth.HashToken(token))] = ap
|
||||
}
|
||||
|
||||
func (f *fakeStore) AgentByToken(_ context.Context, hash []byte) (AgentPrincipal, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
ap, ok := f.agentTokens[hex.EncodeToString(hash)]
|
||||
if !ok {
|
||||
return AgentPrincipal{}, errors.New("no such agent")
|
||||
}
|
||||
return ap, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) VisitorImageKey(_ context.Context, _, visitorID string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.imageKeys[visitorID], nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) VisitorImageKeys(_ context.Context, _, visitorID string) ([]string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if k := f.imageKeys[visitorID]; k != "" {
|
||||
return []string{k}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ForgetVisitor(_ context.Context, _, visitorID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.forgetErr != nil {
|
||||
return f.forgetErr
|
||||
}
|
||||
f.forgotten = append(f.forgotten, visitorID)
|
||||
delete(f.imageKeys, visitorID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeBlob records what the handlers asked storage to do. Deleting is the part
|
||||
// worth recording: an erasure that reports success without removing the object
|
||||
// is the failure this whole path exists to prevent.
|
||||
type fakeBlob struct {
|
||||
mu sync.Mutex
|
||||
deleted []string
|
||||
presigns []string
|
||||
failNext error
|
||||
}
|
||||
|
||||
func (b *fakeBlob) Key(client, site, objectID string, at time.Time) string {
|
||||
return fmt.Sprintf("behavision/%s/%s/%04d/%02d/%02d/%s.jpg",
|
||||
client, site, at.Year(), int(at.Month()), at.Day(), objectID)
|
||||
}
|
||||
|
||||
func (b *fakeBlob) PresignPut(key string, _ time.Duration) (string, http.Header, error) {
|
||||
h := http.Header{}
|
||||
h.Set("x-amz-acl", "private")
|
||||
h.Set("Content-Type", "image/jpeg")
|
||||
return "https://bucket.example.com/" + key + "?X-Amz-Signature=fake", h, nil
|
||||
}
|
||||
|
||||
func (b *fakeBlob) PresignGet(key string, _ time.Duration) (string, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.presigns = append(b.presigns, key)
|
||||
return "https://bucket.example.com/" + key + "?X-Amz-Signature=fake", nil
|
||||
}
|
||||
|
||||
func (b *fakeBlob) Delete(_ context.Context, key string) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.failNext != nil {
|
||||
err := b.failNext
|
||||
b.failNext = nil
|
||||
return err
|
||||
}
|
||||
b.deleted = append(b.deleted, key)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user