feat(persistence): add UserRepository.UpdateImage

Adds the interface method, real repository implementation, and mock
together so the build stays green. UpdateImage sets updated_at
explicitly in the same UPDATE statement, since AvatarTag's ETag
derives from uploaded_image + updated_at.
This commit is contained in:
Deluan 2026-09-07 19:05:14 -04:00
commit ab2c77f7b2
4 changed files with 71 additions and 0 deletions

View file

@ -73,6 +73,8 @@ type UserRepository interface {
Get(id string) (*User, error)
GetAll(options ...QueryOptions) (Users, error)
Put(*User) error
// UpdateImage is the only writer of uploaded_image. See the field comment on User.
UpdateImage(id string, filename string) error
UpdateLastLoginAt(id string) error
UpdateLastAccessAt(id string) error
FindFirstAdmin() (*User, error)

View file

@ -189,6 +189,21 @@ func (r *userRepository) Put(u *model.User) error {
return nil
}
func (r *userRepository) UpdateImage(id string, filename string) error {
upd := Update(r.tableName).
Set("uploaded_image", filename).
Set("updated_at", time.Now()).
Where(Eq{"id": id})
count, err := r.executeSQL(upd)
if err != nil {
return err
}
if count == 0 {
return model.ErrNotFound
}
return nil
}
func (r *userRepository) FindFirstAdmin() (*model.User, error) {
sel := r.selectUserWithLibraries(model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true})
var usr dbUser

View file

@ -5,6 +5,7 @@ import (
"errors"
"slices"
"sync"
"time"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
@ -94,6 +95,45 @@ var _ = Describe("UserRepository", func() {
})
})
Describe("UpdateImage", func() {
It("stores the filename and bumps updated_at", func() {
before := time.Now().Add(-time.Hour)
Expect(repo.UpdateImage(adminUser.ID, "u1_admin.png")).To(Succeed())
usr, err := repo.Get(adminUser.ID)
Expect(err).ToNot(HaveOccurred())
Expect(usr.UploadedImage).To(Equal("u1_admin.png"))
Expect(usr.UpdatedAt).To(BeTemporally(">", before))
})
It("is not erased by a later full-row Put", func() {
Expect(repo.UpdateImage(adminUser.ID, "u1_admin.png")).To(Succeed())
usr, err := repo.Get(adminUser.ID)
Expect(err).ToNot(HaveOccurred())
usr.Name = "Renamed"
Expect(repo.Put(usr)).To(Succeed())
usr, err = repo.Get(adminUser.ID)
Expect(err).ToNot(HaveOccurred())
Expect(usr.UploadedImage).To(Equal("u1_admin.png"))
})
It("clears the filename when given an empty string", func() {
Expect(repo.UpdateImage(adminUser.ID, "u1_admin.png")).To(Succeed())
Expect(repo.UpdateImage(adminUser.ID, "")).To(Succeed())
usr, err := repo.Get(adminUser.ID)
Expect(err).ToNot(HaveOccurred())
Expect(usr.UploadedImage).To(BeEmpty())
})
It("returns ErrNotFound for an unknown id", func() {
err := repo.UpdateImage("no-such-user", "x.png")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("validatePasswordChange", func() {
var loggedUser *model.User

View file

@ -92,6 +92,20 @@ func (u *MockedUserRepo) GetAll(options ...model.QueryOptions) (model.Users, err
return users, nil
}
func (u *MockedUserRepo) UpdateImage(id string, filename string) error {
if u.Error != nil {
return u.Error
}
for _, usr := range u.Data {
if usr.ID == id {
usr.UploadedImage = filename
usr.UpdatedAt = time.Now()
return nil
}
}
return model.ErrNotFound
}
func (u *MockedUserRepo) UpdateLastLoginAt(id string) error {
for _, usr := range u.Data {
if usr.ID == id {