chore: build
ci-api / test (push) Failing after 8m7s
ci-web / test (push) Failing after 5m5s

This commit is contained in:
Xor290
2026-09-20 12:18:33 +02:00
parent 8f4c7fa47a
commit 919c807004
174 changed files with 9669 additions and 1308 deletions
+24 -24
View File
@@ -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)
}
+25 -40
View File
@@ -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))
}
}
+187 -4
View File
@@ -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)
}
}
+7 -8
View File
@@ -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)
}
+59 -30
View File
@@ -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)
}
}