first
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/modules/auth"
|
||||
"backend/internal/modules/users"
|
||||
"backend/internal/platform/security"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func newFakeUserFinder() *fakeUserFinder {
|
||||
return &fakeUserFinder{byID: map[uuid.UUID]*users.User{}, byEmail: map[string]*users.User{}}
|
||||
}
|
||||
|
||||
func (f *fakeUserFinder) add(email, 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}
|
||||
f.byID[u.ID] = u
|
||||
f.byEmail[u.Email] = u
|
||||
return u
|
||||
}
|
||||
|
||||
func (f *fakeUserFinder) FindByEmail(_ context.Context, email string) (*users.User, error) {
|
||||
u, ok := f.byEmail[email]
|
||||
if !ok {
|
||||
return nil, users.ErrNotFound
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserFinder) FindByID(_ context.Context, id uuid.UUID) (*users.User, error) {
|
||||
u, ok := f.byID[id]
|
||||
if !ok {
|
||||
return nil, users.ErrNotFound
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// fakeRefreshStore is an in-memory auth.RefreshStore used to unit-test
|
||||
// rotation/revocation without a real Redis instance.
|
||||
type fakeRefreshStore struct {
|
||||
byToken map[string]uuid.UUID
|
||||
byUser map[uuid.UUID]map[string]bool
|
||||
nextID int
|
||||
}
|
||||
|
||||
func newFakeRefreshStore() *fakeRefreshStore {
|
||||
return &fakeRefreshStore{byToken: map[string]uuid.UUID{}, byUser: map[uuid.UUID]map[string]bool{}}
|
||||
}
|
||||
|
||||
func (f *fakeRefreshStore) newToken() string {
|
||||
f.nextID++
|
||||
return "token-" + uuid.NewString()
|
||||
}
|
||||
|
||||
func (f *fakeRefreshStore) Issue(_ context.Context, _ security.Audience, userID uuid.UUID, _ time.Duration) (string, error) {
|
||||
token := f.newToken()
|
||||
f.byToken[token] = userID
|
||||
if f.byUser[userID] == nil {
|
||||
f.byUser[userID] = map[string]bool{}
|
||||
}
|
||||
f.byUser[userID][token] = true
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (f *fakeRefreshStore) Rotate(ctx context.Context, aud security.Audience, oldToken string, ttl time.Duration) (string, uuid.UUID, error) {
|
||||
userID, ok := f.byToken[oldToken]
|
||||
if !ok {
|
||||
return "", uuid.UUID{}, auth.ErrInvalidRefreshToken
|
||||
}
|
||||
delete(f.byToken, oldToken)
|
||||
delete(f.byUser[userID], oldToken)
|
||||
newToken, err := f.Issue(ctx, aud, userID, ttl)
|
||||
return newToken, userID, err
|
||||
}
|
||||
|
||||
func (f *fakeRefreshStore) Revoke(_ context.Context, _ security.Audience, token string) error {
|
||||
userID, ok := f.byToken[token]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
delete(f.byToken, token)
|
||||
delete(f.byUser[userID], token)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRefreshStore) RevokeAllForUser(_ context.Context, _ security.Audience, userID uuid.UUID) error {
|
||||
for token := range f.byUser[userID] {
|
||||
delete(f.byToken, token)
|
||||
}
|
||||
delete(f.byUser, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestService_Login_Success(t *testing.T) {
|
||||
finder := newFakeUserFinder()
|
||||
finder.add("admin@example.com", "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")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Login_WrongPasswordRejected(t *testing.T) {
|
||||
finder := newFakeUserFinder()
|
||||
finder.add("admin@example.com", "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")
|
||||
if !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Fatalf("Login() error = %v, want ErrInvalidCredentials", err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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")
|
||||
if !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Fatalf("Login() error = %v, want ErrInvalidCredentials for a customer logging into the admin space", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Login_DisabledAccountRejected(t *testing.T) {
|
||||
finder := newFakeUserFinder()
|
||||
finder.add("admin@example.com", "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")
|
||||
if !errors.Is(err, auth.ErrAccountDisabled) {
|
||||
t.Fatalf("Login() error = %v, want ErrAccountDisabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Refresh_RotatesToken(t *testing.T) {
|
||||
finder := newFakeUserFinder()
|
||||
finder.add("admin@example.com", "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")
|
||||
if err != nil {
|
||||
t.Fatalf("Login() error = %v", err)
|
||||
}
|
||||
|
||||
newPair, err := svc.Refresh(context.Background(), security.AudienceAdmin, pair.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Refresh() error = %v", err)
|
||||
}
|
||||
if newPair.RefreshToken == pair.RefreshToken {
|
||||
t.Fatal("Refresh() returned the same refresh token instead of rotating it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Refresh_RejectsReusedToken(t *testing.T) {
|
||||
finder := newFakeUserFinder()
|
||||
finder.add("admin@example.com", "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")
|
||||
if err != nil {
|
||||
t.Fatalf("Login() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.Refresh(context.Background(), security.AudienceAdmin, pair.RefreshToken); err != nil {
|
||||
t.Fatalf("first Refresh() error = %v", err)
|
||||
}
|
||||
// The old (already-rotated) refresh token must never work again.
|
||||
if _, err := svc.Refresh(context.Background(), security.AudienceAdmin, pair.RefreshToken); err == nil {
|
||||
t.Fatal("second Refresh() with the same (rotated-out) token succeeded, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Logout_RevokesToken(t *testing.T) {
|
||||
finder := newFakeUserFinder()
|
||||
finder.add("admin@example.com", "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")
|
||||
if err != nil {
|
||||
t.Fatalf("Login() error = %v", err)
|
||||
}
|
||||
|
||||
if err := svc.Logout(context.Background(), security.AudienceAdmin, pair.RefreshToken); err != nil {
|
||||
t.Fatalf("Logout() error = %v", err)
|
||||
}
|
||||
if _, err := svc.Refresh(context.Background(), security.AudienceAdmin, pair.RefreshToken); err == nil {
|
||||
t.Fatal("Refresh() succeeded after logout, want error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package orders_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/modules/orders"
|
||||
"backend/internal/modules/pricing"
|
||||
"backend/internal/modules/products"
|
||||
"backend/internal/modules/units"
|
||||
)
|
||||
|
||||
// fakeRepository is an in-memory orders.Repository used to unit-test
|
||||
// orders.Service without a real database.
|
||||
type fakeRepository struct {
|
||||
orders map[uuid.UUID]*orders.Order
|
||||
items map[uuid.UUID][]*orders.OrderItem
|
||||
}
|
||||
|
||||
func newFakeRepository() *fakeRepository {
|
||||
return &fakeRepository{orders: map[uuid.UUID]*orders.Order{}, items: map[uuid.UUID][]*orders.OrderItem{}}
|
||||
}
|
||||
|
||||
func (r *fakeRepository) Create(_ context.Context, order *orders.Order, items []*orders.OrderItem) error {
|
||||
cp := *order
|
||||
r.orders[order.ID] = &cp
|
||||
for _, item := range items {
|
||||
item.OrderID = order.ID
|
||||
}
|
||||
r.items[order.ID] = items
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) FindByID(_ context.Context, id uuid.UUID) (*orders.Order, []*orders.OrderItem, error) {
|
||||
o, ok := r.orders[id]
|
||||
if !ok {
|
||||
return nil, nil, orders.ErrNotFound
|
||||
}
|
||||
return o, r.items[id], nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) List(_ context.Context, status string) ([]*orders.Order, error) {
|
||||
var list []*orders.Order
|
||||
for _, o := range r.orders {
|
||||
if status == "" || o.Status == status {
|
||||
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
|
||||
}
|
||||
o.Status = status
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// fakeProducts / fakeUnits / fakePrices back the small consumer-defined
|
||||
// interfaces orders.Service depends on.
|
||||
type fakeProducts struct {
|
||||
byID map[uuid.UUID]*products.Product
|
||||
}
|
||||
|
||||
func (f *fakeProducts) Get(_ context.Context, id uuid.UUID) (*products.Product, error) {
|
||||
p, ok := f.byID[id]
|
||||
if !ok {
|
||||
return nil, products.ErrNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
type fakeUnits struct {
|
||||
byID map[uuid.UUID]*units.Unit
|
||||
}
|
||||
|
||||
func (f *fakeUnits) Get(_ context.Context, id uuid.UUID) (*units.Unit, error) {
|
||||
u, ok := f.byID[id]
|
||||
if !ok {
|
||||
return nil, units.ErrNotFound
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
type fakePrices struct {
|
||||
byID map[uuid.UUID]*pricing.PriceTier
|
||||
}
|
||||
|
||||
func (f *fakePrices) PriceForQuantity(_ context.Context, tierID uuid.UUID, multiplier int64) (int64, *pricing.PriceTier, error) {
|
||||
t, ok := f.byID[tierID]
|
||||
if !ok {
|
||||
return 0, nil, pricing.ErrNotFound
|
||||
}
|
||||
if multiplier < 1 {
|
||||
multiplier = 1
|
||||
}
|
||||
return t.PriceCents * multiplier, t, nil
|
||||
}
|
||||
|
||||
type fakeNotifier struct {
|
||||
newOrderCalls []string
|
||||
statusChangeCalls []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 {
|
||||
repo *fakeRepository
|
||||
products *fakeProducts
|
||||
units *fakeUnits
|
||||
prices *fakePrices
|
||||
notifier *fakeNotifier
|
||||
|
||||
productID uuid.UUID
|
||||
unitID uuid.UUID
|
||||
tierID uuid.UUID
|
||||
}
|
||||
|
||||
func newFixture() *testFixture {
|
||||
productID := uuid.New()
|
||||
unitID := uuid.New()
|
||||
tierID := uuid.New()
|
||||
|
||||
return &testFixture{
|
||||
repo: newFakeRepository(),
|
||||
products: &fakeProducts{byID: map[uuid.UUID]*products.Product{
|
||||
productID: {ID: productID, Name: "Honey jar", Slug: "honey-jar", IsActive: true},
|
||||
}},
|
||||
units: &fakeUnits{byID: map[uuid.UUID]*units.Unit{
|
||||
unitID: {ID: unitID, Name: "Kilogram", Symbol: "kg"},
|
||||
}},
|
||||
prices: &fakePrices{byID: map[uuid.UUID]*pricing.PriceTier{
|
||||
tierID: {ID: tierID, ProductID: productID, UnitID: unitID, Quantity: 5, PriceCents: 4500},
|
||||
}},
|
||||
notifier: &fakeNotifier{},
|
||||
productID: productID,
|
||||
unitID: unitID,
|
||||
tierID: tierID,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *testFixture) service() *orders.Service {
|
||||
return orders.NewService(f.repo, f.products, f.units, f.prices, f.notifier)
|
||||
}
|
||||
|
||||
func TestService_Create_ComputesTotalAndNotifies(t *testing.T) {
|
||||
f := newFixture()
|
||||
svc := f.service()
|
||||
|
||||
order, items, err := svc.Create(context.Background(), orders.CreateInput{
|
||||
CustomerName: "Alice",
|
||||
CustomerEmail: "alice@example.com",
|
||||
Items: []orders.ItemInput{
|
||||
{ProductID: f.productID, PriceTierID: f.tierID, Multiplier: 2},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if len(f.notifier.newOrderCalls) != 1 {
|
||||
t.Fatalf("Create() triggered %d new-order notifications, want exactly 1", len(f.notifier.newOrderCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Create_RejectsEmptyOrder(t *testing.T) {
|
||||
f := newFixture()
|
||||
svc := f.service()
|
||||
|
||||
_, _, err := svc.Create(context.Background(), orders.CreateInput{CustomerName: "Alice", CustomerEmail: "a@example.com"})
|
||||
if !errors.Is(err, orders.ErrEmptyOrder) {
|
||||
t.Fatalf("Create() error = %v, want ErrEmptyOrder", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Create_RejectsTierProductMismatch(t *testing.T) {
|
||||
f := newFixture()
|
||||
svc := f.service()
|
||||
|
||||
otherProduct := uuid.New()
|
||||
f.products.byID[otherProduct] = &products.Product{ID: otherProduct, Name: "Other", Slug: "other", IsActive: true}
|
||||
|
||||
_, _, err := svc.Create(context.Background(), orders.CreateInput{
|
||||
CustomerName: "Alice",
|
||||
CustomerEmail: "alice@example.com",
|
||||
Items: []orders.ItemInput{
|
||||
// tierID actually belongs to f.productID, not otherProduct.
|
||||
{ProductID: otherProduct, PriceTierID: f.tierID, Multiplier: 1},
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, orders.ErrProductMismatch) {
|
||||
t.Fatalf("Create() error = %v, want ErrProductMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateStatus_ValidatesStatus(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)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateStatus() 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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package pricing_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/modules/pricing"
|
||||
)
|
||||
|
||||
// fakeRepository is an in-memory pricing.Repository used to unit-test
|
||||
// pricing.Service without a real database.
|
||||
type fakeRepository struct {
|
||||
tiers map[uuid.UUID]*pricing.PriceTier
|
||||
}
|
||||
|
||||
func newFakeRepository() *fakeRepository {
|
||||
return &fakeRepository{tiers: map[uuid.UUID]*pricing.PriceTier{}}
|
||||
}
|
||||
|
||||
func (r *fakeRepository) Create(_ context.Context, t *pricing.PriceTier) error {
|
||||
cp := *t
|
||||
r.tiers[t.ID] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) FindByID(_ context.Context, id uuid.UUID) (*pricing.PriceTier, error) {
|
||||
t, ok := r.tiers[id]
|
||||
if !ok {
|
||||
return nil, pricing.ErrNotFound
|
||||
}
|
||||
cp := *t
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) ListByProduct(_ context.Context, productID uuid.UUID) ([]*pricing.PriceTier, error) {
|
||||
var list []*pricing.PriceTier
|
||||
for _, t := range r.tiers {
|
||||
if t.ProductID == productID {
|
||||
cp := *t
|
||||
list = append(list, &cp)
|
||||
}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) Update(_ context.Context, t *pricing.PriceTier) error {
|
||||
if _, ok := r.tiers[t.ID]; !ok {
|
||||
return pricing.ErrNotFound
|
||||
}
|
||||
cp := *t
|
||||
r.tiers[t.ID] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) Delete(_ context.Context, id uuid.UUID) error {
|
||||
if _, ok := r.tiers[id]; !ok {
|
||||
return pricing.ErrNotFound
|
||||
}
|
||||
delete(r.tiers, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestService_PriceForQuantity_MultipliesTierPrice(t *testing.T) {
|
||||
repo := newFakeRepository()
|
||||
svc := pricing.NewService(repo)
|
||||
ctx := context.Background()
|
||||
|
||||
productID := uuid.New()
|
||||
unitID := uuid.New()
|
||||
|
||||
// "5 kg -> 45.00" tier, spec section 16 example.
|
||||
tier, err := svc.Create(ctx, productID, unitID, 5, 4500, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
total, gotTier, err := svc.PriceForQuantity(ctx, tier.ID, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("PriceForQuantity() error = %v", err)
|
||||
}
|
||||
if total != 9000 {
|
||||
t.Fatalf("PriceForQuantity() total = %d, want 9000 (2x 4500)", total)
|
||||
}
|
||||
if gotTier.ID != tier.ID {
|
||||
t.Fatalf("PriceForQuantity() returned tier %s, want %s", gotTier.ID, tier.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_PriceForQuantity_ZeroOrNegativeMultiplierTreatedAsOne(t *testing.T) {
|
||||
repo := newFakeRepository()
|
||||
svc := pricing.NewService(repo)
|
||||
ctx := context.Background()
|
||||
|
||||
tier, err := svc.Create(ctx, uuid.New(), uuid.New(), 1, 1000, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
total, _, err := svc.PriceForQuantity(ctx, tier.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("PriceForQuantity() error = %v", err)
|
||||
}
|
||||
if total != 1000 {
|
||||
t.Fatalf("PriceForQuantity() with multiplier=0 total = %d, want 1000 (treated as 1x)", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_PriceForQuantity_UnknownTierRejected(t *testing.T) {
|
||||
svc := pricing.NewService(newFakeRepository())
|
||||
_, _, err := svc.PriceForQuantity(context.Background(), uuid.New(), 1)
|
||||
if !errors.Is(err, pricing.ErrNotFound) {
|
||||
t.Fatalf("PriceForQuantity() error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ListByProduct_FiltersByProduct(t *testing.T) {
|
||||
repo := newFakeRepository()
|
||||
svc := pricing.NewService(repo)
|
||||
ctx := context.Background()
|
||||
|
||||
productA := uuid.New()
|
||||
productB := uuid.New()
|
||||
unitID := uuid.New()
|
||||
|
||||
if _, err := svc.Create(ctx, productA, unitID, 1, 1000, 0); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
if _, err := svc.Create(ctx, productB, unitID, 1, 2000, 0); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
list, err := svc.ListByProduct(ctx, productA)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByProduct() error = %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ProductID != productA {
|
||||
t.Fatalf("ListByProduct(productA) = %+v, want exactly one tier for productA", list)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package security_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/platform/security"
|
||||
)
|
||||
|
||||
const testSecret = "test-secret-please-do-not-use-in-prod"
|
||||
|
||||
func TestIssueAndParseAccessToken_RoundTrip(t *testing.T) {
|
||||
userID := uuid.New()
|
||||
|
||||
token, err := security.IssueAccessToken(testSecret, time.Minute, userID, "admin", security.AudienceAdmin)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueAccessToken() error = %v", err)
|
||||
}
|
||||
|
||||
claims, err := security.ParseAccessToken(testSecret, token, security.AudienceAdmin)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAccessToken() error = %v", err)
|
||||
}
|
||||
|
||||
if claims.Subject != userID.String() {
|
||||
t.Errorf("claims.Subject = %q, want %q", claims.Subject, userID.String())
|
||||
}
|
||||
if claims.Role != "admin" {
|
||||
t.Errorf("claims.Role = %q, want %q", claims.Role, "admin")
|
||||
}
|
||||
if claims.ID == "" {
|
||||
t.Error("claims.ID (jti) is empty, want a non-empty token id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAccessToken_RejectsEmptyRole(t *testing.T) {
|
||||
_, err := security.IssueAccessToken(testSecret, time.Minute, uuid.New(), "", security.AudienceAdmin)
|
||||
if err == nil {
|
||||
t.Fatal("IssueAccessToken() error = nil, want error for empty role")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAccessToken_WrongAudienceRejected(t *testing.T) {
|
||||
userID := uuid.New()
|
||||
token, err := security.IssueAccessToken(testSecret, time.Minute, userID, "admin", security.AudienceAdmin)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueAccessToken() error = %v", err)
|
||||
}
|
||||
|
||||
// An admin-space token must never be accepted as a customer-space token,
|
||||
// even though both spaces share the same signing secret.
|
||||
if _, err := security.ParseAccessToken(testSecret, token, security.AudienceCustomer); err == nil {
|
||||
t.Fatal("ParseAccessToken() error = nil, want error when audience does not match expected space")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAccessToken_WrongSecretRejected(t *testing.T) {
|
||||
token, err := security.IssueAccessToken(testSecret, time.Minute, uuid.New(), "admin", security.AudienceAdmin)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueAccessToken() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := security.ParseAccessToken("a-different-secret", token, security.AudienceAdmin); err == nil {
|
||||
t.Fatal("ParseAccessToken() error = nil, want error for a token signed with a different secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAccessToken_ExpiredTokenRejected(t *testing.T) {
|
||||
token, err := security.IssueAccessToken(testSecret, -time.Minute, uuid.New(), "admin", security.AudienceAdmin)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueAccessToken() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := security.ParseAccessToken(testSecret, token, security.AudienceAdmin); err == nil {
|
||||
t.Fatal("ParseAccessToken() error = nil, want error for an already-expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAccessToken_RejectsNoneAlgorithm(t *testing.T) {
|
||||
// Craft a token that declares "alg":"none" and carries otherwise valid
|
||||
// claims, to make sure the parser refuses it outright rather than
|
||||
// trusting an attacker-chosen algorithm.
|
||||
claims := security.Claims{
|
||||
Role: "admin",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: uuid.New().String(),
|
||||
Audience: jwt.ClaimStrings{string(security.AudienceAdmin)},
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ID: uuid.NewString(),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
|
||||
unsigned, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
|
||||
if err != nil {
|
||||
t.Fatalf("craft none-alg token: %v", err)
|
||||
}
|
||||
|
||||
if _, err := security.ParseAccessToken(testSecret, unsigned, security.AudienceAdmin); err == nil {
|
||||
t.Fatal("ParseAccessToken() error = nil, want error for an alg=none token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAccessToken_RejectsInvalidSubject(t *testing.T) {
|
||||
secretBytes := []byte(testSecret)
|
||||
claims := security.Claims{
|
||||
Role: "admin",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: "not-a-uuid",
|
||||
Audience: jwt.ClaimStrings{string(security.AudienceAdmin)},
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ID: uuid.NewString(),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString(secretBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("sign token: %v", err)
|
||||
}
|
||||
|
||||
if _, err := security.ParseAccessToken(testSecret, signed, security.AudienceAdmin); err == nil {
|
||||
t.Fatal("ParseAccessToken() error = nil, want error for a non-UUID sub claim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAccessToken_RejectsMissingRole(t *testing.T) {
|
||||
secretBytes := []byte(testSecret)
|
||||
claims := security.Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: uuid.New().String(),
|
||||
Audience: jwt.ClaimStrings{string(security.AudienceAdmin)},
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ID: uuid.NewString(),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString(secretBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("sign token: %v", err)
|
||||
}
|
||||
|
||||
if _, err := security.ParseAccessToken(testSecret, signed, security.AudienceAdmin); err == nil {
|
||||
t.Fatal("ParseAccessToken() error = nil, want error when role claim is missing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package security_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"backend/internal/platform/security"
|
||||
)
|
||||
|
||||
func TestHashAndVerifyPassword_RoundTrip(t *testing.T) {
|
||||
hash, err := security.HashPassword("correct-horse-battery-staple")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword() error = %v", err)
|
||||
}
|
||||
|
||||
ok, err := security.VerifyPassword(hash, "correct-horse-battery-staple")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword() error = %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("VerifyPassword() = false, want true for the correct password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPassword_WrongPassword(t *testing.T) {
|
||||
hash, err := security.HashPassword("correct-horse-battery-staple")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword() error = %v", err)
|
||||
}
|
||||
|
||||
ok, err := security.VerifyPassword(hash, "wrong-password")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword() error = %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("VerifyPassword() = true, want false for a wrong password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPassword_UniqueSaltPerCall(t *testing.T) {
|
||||
hash1, err := security.HashPassword("same-password")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword() error = %v", err)
|
||||
}
|
||||
hash2, err := security.HashPassword("same-password")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword() error = %v", err)
|
||||
}
|
||||
if hash1 == hash2 {
|
||||
t.Fatal("two hashes of the same password with random salts must differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPassword_InvalidFormat(t *testing.T) {
|
||||
_, err := security.VerifyPassword("not-a-valid-hash", "whatever")
|
||||
if err == nil {
|
||||
t.Fatal("VerifyPassword() error = nil, want error for malformed hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPassword_TamperedHash(t *testing.T) {
|
||||
hash, err := security.HashPassword("correct-horse-battery-staple")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword() error = %v", err)
|
||||
}
|
||||
|
||||
tampered := hash[:len(hash)-4] + "abcd"
|
||||
ok, _ := security.VerifyPassword(tampered, "correct-horse-battery-staple")
|
||||
if ok {
|
||||
t.Fatal("VerifyPassword() = true for a tampered hash, want false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package site_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"backend/internal/modules/site"
|
||||
)
|
||||
|
||||
// fakeRepository is an in-memory site.Repository backing the single
|
||||
// site_settings row, used to unit-test site.Service without a real database.
|
||||
type fakeRepository struct {
|
||||
settings site.Settings
|
||||
}
|
||||
|
||||
func newFakeRepository() *fakeRepository {
|
||||
return &fakeRepository{settings: site.Settings{ID: 1}}
|
||||
}
|
||||
|
||||
func (r *fakeRepository) Get(_ context.Context) (*site.Settings, error) {
|
||||
cp := r.settings
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
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
|
||||
return nil
|
||||
}
|
||||
|
||||
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")
|
||||
if err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
if updated.Name != "My Shop" || updated.Description != "A small shop" || updated.Slug != "my-shop" {
|
||||
t.Fatalf("Update() = %+v, fields not persisted as expected", updated)
|
||||
}
|
||||
|
||||
fetched, err := svc.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if fetched.Name != "My Shop" {
|
||||
t.Fatalf("Get() after Update() = %+v, want name %q", fetched, "My Shop")
|
||||
}
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
t.Fatalf("Get() before any update = %+v, want empty defaults", settings)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package telegram_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"backend/internal/modules/telegram"
|
||||
)
|
||||
|
||||
// fakeRepository is an in-memory telegram.Repository (the single settings
|
||||
// row) used to unit-test telegram.Service without a real database.
|
||||
type fakeRepository struct {
|
||||
settings telegram.Settings
|
||||
}
|
||||
|
||||
func newFakeRepository() *fakeRepository {
|
||||
return &fakeRepository{settings: telegram.Settings{ID: 1}}
|
||||
}
|
||||
|
||||
func (r *fakeRepository) Get(_ context.Context) (*telegram.Settings, error) {
|
||||
cp := r.settings
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeSender records every message it was asked to send, instead of making
|
||||
// a real network call to the Telegram Bot API.
|
||||
type fakeSender struct {
|
||||
sent []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *fakeSender) Send(_ context.Context, botToken, chatID, text string) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.sent = append(s.sent, text)
|
||||
return nil
|
||||
}
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func TestService_Update_KeepsExistingTokenWhenBlank(t *testing.T) {
|
||||
repo := newFakeRepository()
|
||||
svc := telegram.NewService(repo, &fakeSender{}, discardLogger())
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.Update(ctx, true, "secret-token", "12345", true, false); 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)
|
||||
if err != nil {
|
||||
t.Fatalf("second Update() error = %v", err)
|
||||
}
|
||||
if !settings.NotifyStatusChange || settings.NotifyNewOrder {
|
||||
t.Fatalf("Update() flags = %+v, want NotifyStatusChange=true NotifyNewOrder=false", settings)
|
||||
}
|
||||
if repo.settings.BotToken != "secret-token" {
|
||||
t.Fatalf("Update() with blank bot_token overwrote the stored token: got %q", repo.settings.BotToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Get_NeverExposesRawToken(t *testing.T) {
|
||||
// This mirrors the handler's toResponse() contract: the service layer
|
||||
// itself returns the raw Settings (needed internally to call the
|
||||
// Telegram API), but the HTTP handler must never marshal BotToken back
|
||||
// to the client. This test guards the service-level data so a future
|
||||
// change to the handler can't accidentally start doing so without
|
||||
// resetting the flag on read.
|
||||
repo := newFakeRepository()
|
||||
svc := telegram.NewService(repo, &fakeSender{}, discardLogger())
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.Update(ctx, true, "super-secret", "12345", true, false); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
|
||||
settings, err := svc.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if settings.BotToken != "super-secret" {
|
||||
t.Fatalf("Get() lost the stored bot token: got %q", settings.BotToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_NotifyNewOrder_SkipsWhenDisabled(t *testing.T) {
|
||||
repo := newFakeRepository()
|
||||
sender := &fakeSender{}
|
||||
svc := telegram.NewService(repo, sender, discardLogger())
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.Update(ctx, false, "token", "12345", true, false); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
|
||||
svc.NotifyNewOrder(ctx, "new order!")
|
||||
if len(sender.sent) != 0 {
|
||||
t.Fatalf("NotifyNewOrder() sent a message while Telegram is disabled: %v", sender.sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_NotifyNewOrder_SkipsWhenEventNotEnabled(t *testing.T) {
|
||||
repo := newFakeRepository()
|
||||
sender := &fakeSender{}
|
||||
svc := telegram.NewService(repo, sender, discardLogger())
|
||||
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 {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
|
||||
svc.NotifyNewOrder(ctx, "new order!")
|
||||
if len(sender.sent) != 0 {
|
||||
t.Fatalf("NotifyNewOrder() sent a message while notify_new_order=false: %v", sender.sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_NotifyNewOrder_SendsWhenEnabled(t *testing.T) {
|
||||
repo := newFakeRepository()
|
||||
sender := &fakeSender{}
|
||||
svc := telegram.NewService(repo, sender, discardLogger())
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.Update(ctx, true, "token", "12345", true, false); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
|
||||
svc.NotifyNewOrder(ctx, "new order!")
|
||||
if len(sender.sent) != 1 || sender.sent[0] != "new order!" {
|
||||
t.Fatalf("NotifyNewOrder() sent = %v, want exactly one message \"new order!\"", sender.sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_SendTestMessage_RequiresConfiguredCredentials(t *testing.T) {
|
||||
svc := telegram.NewService(newFakeRepository(), &fakeSender{}, discardLogger())
|
||||
if err := svc.SendTestMessage(context.Background()); err == nil {
|
||||
t.Fatal("SendTestMessage() error = nil, want error when bot token/chat id are not configured")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package users_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/modules/users"
|
||||
"backend/internal/platform/security"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func newFakeRepository() *fakeRepository {
|
||||
return &fakeRepository{
|
||||
byID: make(map[uuid.UUID]*users.User),
|
||||
byEmail: 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
|
||||
}
|
||||
cp := *u
|
||||
r.byID[u.ID] = &cp
|
||||
r.byEmail[u.Email] = u.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) FindByEmail(_ context.Context, email string) (*users.User, error) {
|
||||
id, ok := r.byEmail[email]
|
||||
if !ok {
|
||||
return nil, users.ErrNotFound
|
||||
}
|
||||
cp := *r.byID[id]
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) FindByID(_ context.Context, id uuid.UUID) (*users.User, error) {
|
||||
u, ok := r.byID[id]
|
||||
if !ok {
|
||||
return nil, users.ErrNotFound
|
||||
}
|
||||
cp := *u
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) List(_ context.Context) ([]*users.User, error) {
|
||||
list := make([]*users.User, 0, len(r.byID))
|
||||
for _, u := range r.byID {
|
||||
cp := *u
|
||||
list = append(list, &cp)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) Update(_ context.Context, u *users.User) error {
|
||||
existing, ok := r.byID[u.ID]
|
||||
if !ok {
|
||||
return users.ErrNotFound
|
||||
}
|
||||
if existing.Email != u.Email {
|
||||
if _, taken := r.byEmail[u.Email]; taken {
|
||||
return users.ErrEmailTaken
|
||||
}
|
||||
delete(r.byEmail, existing.Email)
|
||||
r.byEmail[u.Email] = u.ID
|
||||
}
|
||||
cp := *u
|
||||
r.byID[u.ID] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) Delete(_ context.Context, id uuid.UUID) error {
|
||||
u, ok := r.byID[id]
|
||||
if !ok {
|
||||
return users.ErrNotFound
|
||||
}
|
||||
delete(r.byEmail, u.Email)
|
||||
delete(r.byID, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestService_Create_HashesPasswordAndPersists(t *testing.T) {
|
||||
svc := users.NewService(newFakeRepository())
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Create(ctx, "admin@example.com", "super-strong-password", users.RoleAdmin)
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
if user.PasswordHash == "super-strong-password" {
|
||||
t.Fatal("Create() stored the plaintext password instead of a hash")
|
||||
}
|
||||
ok, err := security.VerifyPassword(user.PasswordHash, "super-strong-password")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("stored password hash does not verify against the original password (ok=%v, err=%v)", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatal("Create() error = nil, want error for an invalid role")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Create_DuplicateEmailRejected(t *testing.T) {
|
||||
svc := users.NewService(newFakeRepository())
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.Create(ctx, "dup@example.com", "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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_SetPassword_ChangesHash(t *testing.T) {
|
||||
svc := users.NewService(newFakeRepository())
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Create(ctx, "user@example.com", "first-strong-password", users.RoleAdmin)
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
oldHash := user.PasswordHash
|
||||
|
||||
if err := svc.SetPassword(ctx, user.ID, "second-strong-password"); err != nil {
|
||||
t.Fatalf("SetPassword() error = %v", err)
|
||||
}
|
||||
|
||||
updated, err := svc.FindByID(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByID() error = %v", err)
|
||||
}
|
||||
if updated.PasswordHash == oldHash {
|
||||
t.Fatal("SetPassword() did not change the stored hash")
|
||||
}
|
||||
ok, _ := security.VerifyPassword(updated.PasswordHash, "second-strong-password")
|
||||
if !ok {
|
||||
t.Fatal("new password does not verify against the updated hash")
|
||||
}
|
||||
ok, _ = security.VerifyPassword(updated.PasswordHash, "first-strong-password")
|
||||
if ok {
|
||||
t.Fatal("old password still verifies after SetPassword()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Delete_RemovesUser(t *testing.T) {
|
||||
svc := users.NewService(newFakeRepository())
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Create(ctx, "todelete@example.com", "super-strong-password", users.RoleAdmin)
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
if err := svc.Delete(ctx, user.ID); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
if _, err := svc.FindByID(ctx, user.ID); !errors.Is(err, users.ErrNotFound) {
|
||||
t.Fatalf("FindByID() after delete error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Delete_UnknownUserReturnsNotFound(t *testing.T) {
|
||||
svc := users.NewService(newFakeRepository())
|
||||
if err := svc.Delete(context.Background(), uuid.New()); !errors.Is(err, users.ErrNotFound) {
|
||||
t.Fatalf("Delete() error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user