chore: build
This commit is contained in:
@@ -16,27 +16,27 @@ import (
|
||||
// fakeUserFinder is an in-memory auth.UserFinder used to unit-test
|
||||
// auth.Service without a real users repository/database.
|
||||
type fakeUserFinder struct {
|
||||
byID map[uuid.UUID]*users.User
|
||||
byEmail map[string]*users.User
|
||||
byID map[uuid.UUID]*users.User
|
||||
byUsername map[string]*users.User
|
||||
}
|
||||
|
||||
func newFakeUserFinder() *fakeUserFinder {
|
||||
return &fakeUserFinder{byID: map[uuid.UUID]*users.User{}, byEmail: map[string]*users.User{}}
|
||||
return &fakeUserFinder{byID: map[uuid.UUID]*users.User{}, byUsername: map[string]*users.User{}}
|
||||
}
|
||||
|
||||
func (f *fakeUserFinder) add(email, password, role string, active bool) *users.User {
|
||||
func (f *fakeUserFinder) add(username, password, role string, active bool) *users.User {
|
||||
hash, err := security.HashPassword(password)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
u := &users.User{ID: uuid.New(), Email: email, PasswordHash: hash, Role: role, IsActive: active}
|
||||
u := &users.User{ID: uuid.New(), Username: username, PasswordHash: hash, Role: role, IsActive: active}
|
||||
f.byID[u.ID] = u
|
||||
f.byEmail[u.Email] = u
|
||||
f.byUsername[u.Username] = u
|
||||
return u
|
||||
}
|
||||
|
||||
func (f *fakeUserFinder) FindByEmail(_ context.Context, email string) (*users.User, error) {
|
||||
u, ok := f.byEmail[email]
|
||||
func (f *fakeUserFinder) FindByUsername(_ context.Context, username string) (*users.User, error) {
|
||||
u, ok := f.byUsername[username]
|
||||
if !ok {
|
||||
return nil, users.ErrNotFound
|
||||
}
|
||||
@@ -109,28 +109,28 @@ func (f *fakeRefreshStore) RevokeAllForUser(_ context.Context, _ security.Audien
|
||||
|
||||
func TestService_Login_Success(t *testing.T) {
|
||||
finder := newFakeUserFinder()
|
||||
finder.add("admin@example.com", "correct-password", users.RoleAdmin, true)
|
||||
finder.add("admin", "correct-password", users.RoleAdmin, true)
|
||||
|
||||
svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour)
|
||||
|
||||
pair, user, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password")
|
||||
pair, user, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin", "correct-password")
|
||||
if err != nil {
|
||||
t.Fatalf("Login() error = %v", err)
|
||||
}
|
||||
if pair.AccessToken == "" || pair.RefreshToken == "" {
|
||||
t.Fatal("Login() returned an empty token pair")
|
||||
}
|
||||
if user.Email != "admin@example.com" {
|
||||
t.Fatalf("Login() user = %+v, want email admin@example.com", user)
|
||||
if user.Username != "admin" {
|
||||
t.Fatalf("Login() user = %+v, want username admin", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Login_WrongPasswordRejected(t *testing.T) {
|
||||
finder := newFakeUserFinder()
|
||||
finder.add("admin@example.com", "correct-password", users.RoleAdmin, true)
|
||||
finder.add("admin", "correct-password", users.RoleAdmin, true)
|
||||
svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour)
|
||||
|
||||
_, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "wrong-password")
|
||||
_, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin", "wrong-password")
|
||||
if !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Fatalf("Login() error = %v, want ErrInvalidCredentials", err)
|
||||
}
|
||||
@@ -140,10 +140,10 @@ func TestService_Login_WrongRoleSpaceRejected(t *testing.T) {
|
||||
// A customer account must not be able to log into the admin space, even
|
||||
// with the correct password -- the two spaces are strictly separated.
|
||||
finder := newFakeUserFinder()
|
||||
finder.add("shopper@example.com", "correct-password", users.RoleCustomer, true)
|
||||
finder.add("shopper", "correct-password", users.RoleCustomer, true)
|
||||
svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour)
|
||||
|
||||
_, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "shopper@example.com", "correct-password")
|
||||
_, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "shopper", "correct-password")
|
||||
if !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Fatalf("Login() error = %v, want ErrInvalidCredentials for a customer logging into the admin space", err)
|
||||
}
|
||||
@@ -151,10 +151,10 @@ func TestService_Login_WrongRoleSpaceRejected(t *testing.T) {
|
||||
|
||||
func TestService_Login_DisabledAccountRejected(t *testing.T) {
|
||||
finder := newFakeUserFinder()
|
||||
finder.add("admin@example.com", "correct-password", users.RoleAdmin, false)
|
||||
finder.add("admin", "correct-password", users.RoleAdmin, false)
|
||||
svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour)
|
||||
|
||||
_, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password")
|
||||
_, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin", "correct-password")
|
||||
if !errors.Is(err, auth.ErrAccountDisabled) {
|
||||
t.Fatalf("Login() error = %v, want ErrAccountDisabled", err)
|
||||
}
|
||||
@@ -162,10 +162,10 @@ func TestService_Login_DisabledAccountRejected(t *testing.T) {
|
||||
|
||||
func TestService_Refresh_RotatesToken(t *testing.T) {
|
||||
finder := newFakeUserFinder()
|
||||
finder.add("admin@example.com", "correct-password", users.RoleAdmin, true)
|
||||
finder.add("admin", "correct-password", users.RoleAdmin, true)
|
||||
svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour)
|
||||
|
||||
pair, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password")
|
||||
pair, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin", "correct-password")
|
||||
if err != nil {
|
||||
t.Fatalf("Login() error = %v", err)
|
||||
}
|
||||
@@ -181,10 +181,10 @@ func TestService_Refresh_RotatesToken(t *testing.T) {
|
||||
|
||||
func TestService_Refresh_RejectsReusedToken(t *testing.T) {
|
||||
finder := newFakeUserFinder()
|
||||
finder.add("admin@example.com", "correct-password", users.RoleAdmin, true)
|
||||
finder.add("admin", "correct-password", users.RoleAdmin, true)
|
||||
svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour)
|
||||
|
||||
pair, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password")
|
||||
pair, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin", "correct-password")
|
||||
if err != nil {
|
||||
t.Fatalf("Login() error = %v", err)
|
||||
}
|
||||
@@ -200,10 +200,10 @@ func TestService_Refresh_RejectsReusedToken(t *testing.T) {
|
||||
|
||||
func TestService_Logout_RevokesToken(t *testing.T) {
|
||||
finder := newFakeUserFinder()
|
||||
finder.add("admin@example.com", "correct-password", users.RoleAdmin, true)
|
||||
finder.add("admin", "correct-password", users.RoleAdmin, true)
|
||||
svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour)
|
||||
|
||||
pair, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password")
|
||||
pair, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin", "correct-password")
|
||||
if err != nil {
|
||||
t.Fatalf("Login() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -42,23 +42,22 @@ func (r *fakeRepository) FindByID(_ context.Context, id uuid.UUID) (*orders.Orde
|
||||
return o, r.items[id], nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) List(_ context.Context, status string) ([]*orders.Order, error) {
|
||||
func (r *fakeRepository) List(_ context.Context) ([]*orders.Order, error) {
|
||||
var list []*orders.Order
|
||||
for _, o := range r.orders {
|
||||
if status == "" || o.Status == status {
|
||||
list = append(list, o)
|
||||
}
|
||||
list = append(list, o)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) UpdateStatus(_ context.Context, id uuid.UUID, status string) (*orders.Order, error) {
|
||||
o, ok := r.orders[id]
|
||||
if !ok {
|
||||
return nil, orders.ErrNotFound
|
||||
func (r *fakeRepository) ListByCustomer(_ context.Context, customerID uuid.UUID) ([]*orders.Order, error) {
|
||||
var list []*orders.Order
|
||||
for _, o := range r.orders {
|
||||
if o.CustomerID != nil && *o.CustomerID == customerID {
|
||||
list = append(list, o)
|
||||
}
|
||||
}
|
||||
o.Status = status
|
||||
return o, nil
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// fakeProducts / fakeUnits / fakePrices back the small consumer-defined
|
||||
@@ -103,18 +102,13 @@ func (f *fakePrices) PriceForQuantity(_ context.Context, tierID uuid.UUID, multi
|
||||
}
|
||||
|
||||
type fakeNotifier struct {
|
||||
newOrderCalls []string
|
||||
statusChangeCalls []string
|
||||
newOrderCalls []string
|
||||
}
|
||||
|
||||
func (f *fakeNotifier) NotifyNewOrder(_ context.Context, summary string) {
|
||||
f.newOrderCalls = append(f.newOrderCalls, summary)
|
||||
}
|
||||
|
||||
func (f *fakeNotifier) NotifyOrderStatusChange(_ context.Context, summary string) {
|
||||
f.statusChangeCalls = append(f.statusChangeCalls, summary)
|
||||
}
|
||||
|
||||
// testFixture wires a full set of fakes with one product/unit/tier, ready
|
||||
// for a service under test.
|
||||
type testFixture struct {
|
||||
@@ -137,7 +131,7 @@ func newFixture() *testFixture {
|
||||
return &testFixture{
|
||||
repo: newFakeRepository(),
|
||||
products: &fakeProducts{byID: map[uuid.UUID]*products.Product{
|
||||
productID: {ID: productID, Name: "Honey jar", Slug: "honey-jar", IsActive: true},
|
||||
productID: {ID: productID, Name: "Honey jar", IsActive: true},
|
||||
}},
|
||||
units: &fakeUnits{byID: map[uuid.UUID]*units.Unit{
|
||||
unitID: {ID: unitID, Name: "Kilogram", Symbol: "kg"},
|
||||
@@ -173,9 +167,6 @@ func TestService_Create_ComputesTotalAndNotifies(t *testing.T) {
|
||||
if order.TotalCents != 9000 {
|
||||
t.Fatalf("Create() order.TotalCents = %d, want 9000 (2x 4500)", order.TotalCents)
|
||||
}
|
||||
if order.Status != orders.StatusPending {
|
||||
t.Fatalf("Create() order.Status = %q, want %q", order.Status, orders.StatusPending)
|
||||
}
|
||||
if len(items) != 1 || items[0].ProductName != "Honey jar" || items[0].UnitSymbol != "kg" {
|
||||
t.Fatalf("Create() items = %+v, want snapshot of product name/unit symbol", items)
|
||||
}
|
||||
@@ -199,7 +190,7 @@ func TestService_Create_RejectsTierProductMismatch(t *testing.T) {
|
||||
svc := f.service()
|
||||
|
||||
otherProduct := uuid.New()
|
||||
f.products.byID[otherProduct] = &products.Product{ID: otherProduct, Name: "Other", Slug: "other", IsActive: true}
|
||||
f.products.byID[otherProduct] = &products.Product{ID: otherProduct, Name: "Other", IsActive: true}
|
||||
|
||||
_, _, err := svc.Create(context.Background(), orders.CreateInput{
|
||||
CustomerName: "Alice",
|
||||
@@ -214,31 +205,25 @@ func TestService_Create_RejectsTierProductMismatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateStatus_ValidatesStatus(t *testing.T) {
|
||||
func TestService_List_ReturnsAllOrders(t *testing.T) {
|
||||
f := newFixture()
|
||||
svc := f.service()
|
||||
|
||||
order, _, err := svc.Create(context.Background(), orders.CreateInput{
|
||||
CustomerName: "Alice",
|
||||
CustomerEmail: "alice@example.com",
|
||||
Items: []orders.ItemInput{{ProductID: f.productID, PriceTierID: f.tierID, Multiplier: 1}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
for range 2 {
|
||||
if _, _, err := svc.Create(context.Background(), orders.CreateInput{
|
||||
CustomerName: "Alice",
|
||||
CustomerEmail: "alice@example.com",
|
||||
Items: []orders.ItemInput{{ProductID: f.productID, PriceTierID: f.tierID, Multiplier: 1}},
|
||||
}); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := svc.UpdateStatus(context.Background(), order.ID, "not-a-real-status"); !errors.Is(err, orders.ErrInvalidStatus) {
|
||||
t.Fatalf("UpdateStatus() error = %v, want ErrInvalidStatus", err)
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateStatus(context.Background(), order.ID, orders.StatusConfirmed)
|
||||
list, err := svc.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateStatus() error = %v", err)
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
if updated.Status != orders.StatusConfirmed {
|
||||
t.Fatalf("UpdateStatus() status = %q, want %q", updated.Status, orders.StatusConfirmed)
|
||||
}
|
||||
if len(f.notifier.statusChangeCalls) != 1 {
|
||||
t.Fatalf("UpdateStatus() triggered %d status-change notifications, want exactly 1", len(f.notifier.statusChangeCalls))
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("List() returned %d orders, want 2", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/modules/site"
|
||||
)
|
||||
|
||||
@@ -17,6 +19,9 @@ func newFakeRepository() *fakeRepository {
|
||||
return &fakeRepository{settings: site.Settings{ID: 1}}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
func (r *fakeRepository) Get(_ context.Context) (*site.Settings, error) {
|
||||
cp := r.settings
|
||||
return &cp, nil
|
||||
@@ -25,7 +30,34 @@ func (r *fakeRepository) Get(_ context.Context) (*site.Settings, error) {
|
||||
func (r *fakeRepository) Update(_ context.Context, s *site.Settings) error {
|
||||
r.settings.Name = s.Name
|
||||
r.settings.Description = s.Description
|
||||
r.settings.Slug = s.Slug
|
||||
r.settings.OrdersEnabled = s.OrdersEnabled
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) UpdateAppearance(_ context.Context, s *site.Settings) error {
|
||||
r.settings.HeaderBgColor = s.HeaderBgColor
|
||||
r.settings.HeaderTextColor = s.HeaderTextColor
|
||||
r.settings.BodyBgColor = s.BodyBgColor
|
||||
r.settings.BodyTextColor = s.BodyTextColor
|
||||
r.settings.FooterBgColor = s.FooterBgColor
|
||||
r.settings.FooterTextColor = s.FooterTextColor
|
||||
r.settings.AccentColor = s.AccentColor
|
||||
r.settings.ProductLayout = s.ProductLayout
|
||||
r.settings.ProductColumns = s.ProductColumns
|
||||
r.settings.ProductScroll = s.ProductScroll
|
||||
r.settings.ContactCardTransparent = s.ContactCardTransparent
|
||||
r.settings.ContactCardBgColor = s.ContactCardBgColor
|
||||
r.settings.LogoMediaID = s.LogoMediaID
|
||||
r.settings.HeroMediaID = s.HeroMediaID
|
||||
r.settings.HeroPages = s.HeroPages
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) UpdateCustomerAuth(_ context.Context, s *site.Settings) error {
|
||||
r.settings.CustomerLoginEnabled = s.CustomerLoginEnabled
|
||||
r.settings.CustomerRegistrationEnabled = s.CustomerRegistrationEnabled
|
||||
r.settings.CustomerVerificationRequired = s.CustomerVerificationRequired
|
||||
r.settings.VerificationContactLinkID = s.VerificationContactLinkID
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -33,11 +65,11 @@ func TestService_Update_PersistsFields(t *testing.T) {
|
||||
svc := site.NewService(newFakeRepository())
|
||||
ctx := context.Background()
|
||||
|
||||
updated, err := svc.Update(ctx, "My Shop", "A small shop", "my-shop")
|
||||
updated, err := svc.Update(ctx, "My Shop", "A small shop", true)
|
||||
if err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
if updated.Name != "My Shop" || updated.Description != "A small shop" || updated.Slug != "my-shop" {
|
||||
if updated.Name != "My Shop" || updated.Description != "A small shop" || !updated.OrdersEnabled {
|
||||
t.Fatalf("Update() = %+v, fields not persisted as expected", updated)
|
||||
}
|
||||
|
||||
@@ -50,13 +82,164 @@ func TestService_Update_PersistsFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateAppearance_DoesNotRequireIdentityFields(t *testing.T) {
|
||||
svc := site.NewService(newFakeRepository())
|
||||
ctx := context.Background()
|
||||
|
||||
// The Appearance admin page never touches name/description, so it must
|
||||
// be able to save even when the site identity was never configured
|
||||
// (name still empty) -- this used to fail because both concerns shared
|
||||
// one PUT that validated identity fields on every save.
|
||||
updated, err := svc.UpdateAppearance(ctx, site.AppearanceInput{
|
||||
HeaderBgColor: strPtr("#111111"),
|
||||
HeaderTextColor: strPtr("#ffffff"),
|
||||
BodyBgColor: strPtr("#222222"),
|
||||
BodyTextColor: strPtr("#eeeeee"),
|
||||
FooterBgColor: strPtr("#333333"),
|
||||
FooterTextColor: strPtr("#dddddd"),
|
||||
AccentColor: strPtr("#ff00ff"),
|
||||
ProductLayout: strPtr("list"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateAppearance() error = %v", err)
|
||||
}
|
||||
if updated.AccentColor != "#ff00ff" || updated.ProductLayout != "list" {
|
||||
t.Fatalf("UpdateAppearance() = %+v, fields not persisted as expected", updated)
|
||||
}
|
||||
if updated.Name != "" {
|
||||
t.Fatalf("UpdateAppearance() = %+v, should not touch identity fields", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateAppearance_PartialUpdateKeepsOtherFields(t *testing.T) {
|
||||
svc := site.NewService(newFakeRepository())
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.UpdateAppearance(ctx, site.AppearanceInput{
|
||||
HeaderBgColor: strPtr("#111111"),
|
||||
HeaderTextColor: strPtr("#ffffff"),
|
||||
BodyBgColor: strPtr("#222222"),
|
||||
BodyTextColor: strPtr("#eeeeee"),
|
||||
FooterBgColor: strPtr("#333333"),
|
||||
FooterTextColor: strPtr("#dddddd"),
|
||||
AccentColor: strPtr("#ff00ff"),
|
||||
ProductLayout: strPtr("grid"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateAppearance() error = %v", err)
|
||||
}
|
||||
|
||||
// The admin only changes the product layout; every color must keep its
|
||||
// previously saved value, not be wiped out.
|
||||
updated, err := svc.UpdateAppearance(ctx, site.AppearanceInput{ProductLayout: strPtr("list")})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateAppearance() partial error = %v", err)
|
||||
}
|
||||
if updated.ProductLayout != "list" {
|
||||
t.Fatalf("UpdateAppearance() partial = %+v, want product_layout list", updated)
|
||||
}
|
||||
if updated.AccentColor != "#ff00ff" || updated.HeaderBgColor != "#111111" {
|
||||
t.Fatalf("UpdateAppearance() partial = %+v, unrelated fields should be unchanged", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateAppearance_LogoAndHeroClearedOnlyWhenExplicit(t *testing.T) {
|
||||
svc := site.NewService(newFakeRepository())
|
||||
ctx := context.Background()
|
||||
|
||||
logoID := uuid.New()
|
||||
updated, err := svc.UpdateAppearance(ctx, site.AppearanceInput{
|
||||
LogoMediaIDSet: true,
|
||||
LogoMediaID: &logoID,
|
||||
HeroPages: strPtr("home,contact"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateAppearance() error = %v", err)
|
||||
}
|
||||
if updated.LogoMediaID == nil || *updated.LogoMediaID != logoID {
|
||||
t.Fatalf("UpdateAppearance() = %+v, want logo set", updated)
|
||||
}
|
||||
if updated.HeroPages != "home,contact" {
|
||||
t.Fatalf("UpdateAppearance() = %+v, want hero_pages persisted", updated)
|
||||
}
|
||||
|
||||
// Saving an unrelated field (no LogoMediaIDSet) must not wipe the logo.
|
||||
updated, err = svc.UpdateAppearance(ctx, site.AppearanceInput{ProductLayout: strPtr("list")})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateAppearance() second call error = %v", err)
|
||||
}
|
||||
if updated.LogoMediaID == nil || *updated.LogoMediaID != logoID {
|
||||
t.Fatalf("UpdateAppearance() = %+v, logo should survive an unrelated save", updated)
|
||||
}
|
||||
|
||||
// Explicitly clearing the logo (Set=true, ID=nil) must remove it.
|
||||
updated, err = svc.UpdateAppearance(ctx, site.AppearanceInput{LogoMediaIDSet: true, LogoMediaID: nil})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateAppearance() clear error = %v", err)
|
||||
}
|
||||
if updated.LogoMediaID != nil {
|
||||
t.Fatalf("UpdateAppearance() = %+v, want logo cleared", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateCustomerAuth_PartialUpdateKeepsOtherFields(t *testing.T) {
|
||||
svc := site.NewService(newFakeRepository())
|
||||
ctx := context.Background()
|
||||
|
||||
linkID := uuid.New()
|
||||
_, err := svc.UpdateCustomerAuth(ctx, site.CustomerAuthInput{
|
||||
LoginEnabled: boolPtr(true),
|
||||
VerificationRequired: boolPtr(true),
|
||||
VerificationContactLinkSet: true,
|
||||
VerificationContactLinkID: &linkID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateCustomerAuth() error = %v", err)
|
||||
}
|
||||
|
||||
// Flipping just login_enabled must not disturb the verification
|
||||
// requirement or the chosen contact link.
|
||||
updated, err := svc.UpdateCustomerAuth(ctx, site.CustomerAuthInput{LoginEnabled: boolPtr(false)})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateCustomerAuth() partial error = %v", err)
|
||||
}
|
||||
if updated.CustomerLoginEnabled {
|
||||
t.Fatalf("UpdateCustomerAuth() partial = %+v, want login disabled", updated)
|
||||
}
|
||||
if !updated.CustomerVerificationRequired {
|
||||
t.Fatalf("UpdateCustomerAuth() partial = %+v, verification_required should be unchanged", updated)
|
||||
}
|
||||
if updated.VerificationContactLinkID == nil || *updated.VerificationContactLinkID != linkID {
|
||||
t.Fatalf("UpdateCustomerAuth() partial = %+v, contact link should be unchanged", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateCustomerAuth_CanClearContactLink(t *testing.T) {
|
||||
svc := site.NewService(newFakeRepository())
|
||||
ctx := context.Background()
|
||||
|
||||
linkID := uuid.New()
|
||||
_, err := svc.UpdateCustomerAuth(ctx, site.CustomerAuthInput{VerificationContactLinkSet: true, VerificationContactLinkID: &linkID})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateCustomerAuth() error = %v", err)
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateCustomerAuth(ctx, site.CustomerAuthInput{VerificationContactLinkSet: true, VerificationContactLinkID: nil})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateCustomerAuth() clear error = %v", err)
|
||||
}
|
||||
if updated.VerificationContactLinkID != nil {
|
||||
t.Fatalf("UpdateCustomerAuth() clear = %+v, want nil contact link", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Get_DefaultsBeforeAnyUpdate(t *testing.T) {
|
||||
svc := site.NewService(newFakeRepository())
|
||||
settings, err := svc.Get(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if settings.Name != "" || settings.Slug != "" {
|
||||
if settings.Name != "" {
|
||||
t.Fatalf("Get() before any update = %+v, want empty defaults", settings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ func (r *fakeRepository) Update(_ context.Context, s *telegram.Settings) error {
|
||||
r.settings.Enabled = s.Enabled
|
||||
r.settings.ChatID = s.ChatID
|
||||
r.settings.NotifyNewOrder = s.NotifyNewOrder
|
||||
r.settings.NotifyStatusChange = s.NotifyStatusChange
|
||||
if s.BotToken != "" {
|
||||
r.settings.BotToken = s.BotToken
|
||||
}
|
||||
@@ -59,16 +58,16 @@ func TestService_Update_KeepsExistingTokenWhenBlank(t *testing.T) {
|
||||
svc := telegram.NewService(repo, &fakeSender{}, discardLogger())
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.Update(ctx, true, "secret-token", "12345", true, false); err != nil {
|
||||
if _, err := svc.Update(ctx, true, "secret-token", "12345", true); err != nil {
|
||||
t.Fatalf("first Update() error = %v", err)
|
||||
}
|
||||
|
||||
// Flip a flag without resending the token.
|
||||
settings, err := svc.Update(ctx, true, "", "12345", false, true)
|
||||
settings, err := svc.Update(ctx, true, "", "12345", false)
|
||||
if err != nil {
|
||||
t.Fatalf("second Update() error = %v", err)
|
||||
}
|
||||
if !settings.NotifyStatusChange || settings.NotifyNewOrder {
|
||||
if settings.NotifyNewOrder {
|
||||
t.Fatalf("Update() flags = %+v, want NotifyStatusChange=true NotifyNewOrder=false", settings)
|
||||
}
|
||||
if repo.settings.BotToken != "secret-token" {
|
||||
@@ -87,7 +86,7 @@ func TestService_Get_NeverExposesRawToken(t *testing.T) {
|
||||
svc := telegram.NewService(repo, &fakeSender{}, discardLogger())
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.Update(ctx, true, "super-secret", "12345", true, false); err != nil {
|
||||
if _, err := svc.Update(ctx, true, "super-secret", "12345", true); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -106,7 +105,7 @@ func TestService_NotifyNewOrder_SkipsWhenDisabled(t *testing.T) {
|
||||
svc := telegram.NewService(repo, sender, discardLogger())
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.Update(ctx, false, "token", "12345", true, false); err != nil {
|
||||
if _, err := svc.Update(ctx, false, "token", "12345", true); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -123,7 +122,7 @@ func TestService_NotifyNewOrder_SkipsWhenEventNotEnabled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Enabled overall, but the "new order" event specifically is off.
|
||||
if _, err := svc.Update(ctx, true, "token", "12345", false, true); err != nil {
|
||||
if _, err := svc.Update(ctx, true, "token", "12345", false); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -139,7 +138,7 @@ func TestService_NotifyNewOrder_SendsWhenEnabled(t *testing.T) {
|
||||
svc := telegram.NewService(repo, sender, discardLogger())
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.Update(ctx, true, "token", "12345", true, false); err != nil {
|
||||
if _, err := svc.Update(ctx, true, "token", "12345", true); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,29 +14,29 @@ import (
|
||||
// fakeRepository is an in-memory users.Repository used to unit-test
|
||||
// users.Service without a real database.
|
||||
type fakeRepository struct {
|
||||
byID map[uuid.UUID]*users.User
|
||||
byEmail map[string]uuid.UUID
|
||||
byID map[uuid.UUID]*users.User
|
||||
byUsername map[string]uuid.UUID
|
||||
}
|
||||
|
||||
func newFakeRepository() *fakeRepository {
|
||||
return &fakeRepository{
|
||||
byID: make(map[uuid.UUID]*users.User),
|
||||
byEmail: make(map[string]uuid.UUID),
|
||||
byID: make(map[uuid.UUID]*users.User),
|
||||
byUsername: make(map[string]uuid.UUID),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fakeRepository) Create(_ context.Context, u *users.User) error {
|
||||
if _, exists := r.byEmail[u.Email]; exists {
|
||||
return users.ErrEmailTaken
|
||||
if _, exists := r.byUsername[u.Username]; exists {
|
||||
return users.ErrUsernameTaken
|
||||
}
|
||||
cp := *u
|
||||
r.byID[u.ID] = &cp
|
||||
r.byEmail[u.Email] = u.ID
|
||||
r.byUsername[u.Username] = u.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) FindByEmail(_ context.Context, email string) (*users.User, error) {
|
||||
id, ok := r.byEmail[email]
|
||||
func (r *fakeRepository) FindByUsername(_ context.Context, username string) (*users.User, error) {
|
||||
id, ok := r.byUsername[username]
|
||||
if !ok {
|
||||
return nil, users.ErrNotFound
|
||||
}
|
||||
@@ -67,12 +67,12 @@ func (r *fakeRepository) Update(_ context.Context, u *users.User) error {
|
||||
if !ok {
|
||||
return users.ErrNotFound
|
||||
}
|
||||
if existing.Email != u.Email {
|
||||
if _, taken := r.byEmail[u.Email]; taken {
|
||||
return users.ErrEmailTaken
|
||||
if existing.Username != u.Username {
|
||||
if _, taken := r.byUsername[u.Username]; taken {
|
||||
return users.ErrUsernameTaken
|
||||
}
|
||||
delete(r.byEmail, existing.Email)
|
||||
r.byEmail[u.Email] = u.ID
|
||||
delete(r.byUsername, existing.Username)
|
||||
r.byUsername[u.Username] = u.ID
|
||||
}
|
||||
cp := *u
|
||||
r.byID[u.ID] = &cp
|
||||
@@ -84,16 +84,26 @@ func (r *fakeRepository) Delete(_ context.Context, id uuid.UUID) error {
|
||||
if !ok {
|
||||
return users.ErrNotFound
|
||||
}
|
||||
delete(r.byEmail, u.Email)
|
||||
delete(r.byUsername, u.Username)
|
||||
delete(r.byID, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeAccountsGate is an in-memory users.AccountsGate used to unit-test the
|
||||
// customer-role creation gate without a real site.Service.
|
||||
type fakeAccountsGate struct {
|
||||
available bool
|
||||
}
|
||||
|
||||
func (g fakeAccountsGate) CustomerAccountsAvailable(context.Context) (bool, error) {
|
||||
return g.available, nil
|
||||
}
|
||||
|
||||
func TestService_Create_HashesPasswordAndPersists(t *testing.T) {
|
||||
svc := users.NewService(newFakeRepository())
|
||||
svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true})
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Create(ctx, "admin@example.com", "super-strong-password", users.RoleAdmin)
|
||||
user, err := svc.Create(ctx, "admin", "super-strong-password", users.RoleAdmin)
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
@@ -108,30 +118,30 @@ func TestService_Create_HashesPasswordAndPersists(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_Create_RejectsInvalidRole(t *testing.T) {
|
||||
svc := users.NewService(newFakeRepository())
|
||||
if _, err := svc.Create(context.Background(), "a@example.com", "super-strong-password", "superadmin"); err == nil {
|
||||
svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true})
|
||||
if _, err := svc.Create(context.Background(), "user-a", "super-strong-password", "superadmin"); err == nil {
|
||||
t.Fatal("Create() error = nil, want error for an invalid role")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Create_DuplicateEmailRejected(t *testing.T) {
|
||||
svc := users.NewService(newFakeRepository())
|
||||
func TestService_Create_DuplicateUsernameRejected(t *testing.T) {
|
||||
svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true})
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.Create(ctx, "dup@example.com", "super-strong-password", users.RoleAdmin); err != nil {
|
||||
if _, err := svc.Create(ctx, "dup-user", "super-strong-password", users.RoleAdmin); err != nil {
|
||||
t.Fatalf("first Create() error = %v", err)
|
||||
}
|
||||
_, err := svc.Create(ctx, "dup@example.com", "another-strong-password", users.RoleAdmin)
|
||||
if !errors.Is(err, users.ErrEmailTaken) {
|
||||
t.Fatalf("second Create() error = %v, want ErrEmailTaken", err)
|
||||
_, err := svc.Create(ctx, "dup-user", "another-strong-password", users.RoleAdmin)
|
||||
if !errors.Is(err, users.ErrUsernameTaken) {
|
||||
t.Fatalf("second Create() error = %v, want ErrUsernameTaken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_SetPassword_ChangesHash(t *testing.T) {
|
||||
svc := users.NewService(newFakeRepository())
|
||||
svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true})
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Create(ctx, "user@example.com", "first-strong-password", users.RoleAdmin)
|
||||
user, err := svc.Create(ctx, "user1", "first-strong-password", users.RoleAdmin)
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
@@ -159,10 +169,10 @@ func TestService_SetPassword_ChangesHash(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_Delete_RemovesUser(t *testing.T) {
|
||||
svc := users.NewService(newFakeRepository())
|
||||
svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true})
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Create(ctx, "todelete@example.com", "super-strong-password", users.RoleAdmin)
|
||||
user, err := svc.Create(ctx, "todelete", "super-strong-password", users.RoleAdmin)
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
@@ -176,8 +186,27 @@ func TestService_Delete_RemovesUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_Delete_UnknownUserReturnsNotFound(t *testing.T) {
|
||||
svc := users.NewService(newFakeRepository())
|
||||
svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true})
|
||||
if err := svc.Delete(context.Background(), uuid.New()); !errors.Is(err, users.ErrNotFound) {
|
||||
t.Fatalf("Delete() error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Create_CustomerRoleRejectedWhenAccountsDisabled(t *testing.T) {
|
||||
svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: false})
|
||||
_, err := svc.Create(context.Background(), "some-customer", "super-strong-password", users.RoleCustomer)
|
||||
if !errors.Is(err, users.ErrCustomerAccountsDisabled) {
|
||||
t.Fatalf("Create() error = %v, want ErrCustomerAccountsDisabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Create_CustomerRoleAllowedWhenAccountsEnabled(t *testing.T) {
|
||||
svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true})
|
||||
user, err := svc.Create(context.Background(), "some-customer", "super-strong-password", users.RoleCustomer)
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
if user.Role != users.RoleCustomer {
|
||||
t.Fatalf("Create() role = %q, want %q", user.Role, users.RoleCustomer)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user