Improve README SEO and increase test coverage to 50%
Optimized README for better discoverability with keywords: api, habits, self-hosted. Consolidated content to reduce redundancy while maintaining clarity. Added comprehensive test coverage across multiple layers: - Infrastructure: bcrypt hashing, JWT tokens, configuration validation - Application commands: user deletion, password reset, token revocation, email verification - Application queries: login, token refresh - Domain entities: refresh tokens, password reset tokens Coverage increased from 42.5% to 50.8% with meaningful business logic tests. Fixed integration test handler initialization with correct parameters.
This commit is contained in:
@@ -1,19 +1,32 @@
|
||||
# Apocapoc
|
||||
# Apocapoc API - Self-Hosted Habit Tracker
|
||||
|
||||
Self-hosted habit tracking service with a clean, hexagonal architecture.
|
||||
**Lightweight REST API for habit tracking built with Go.** Designed for developers who want full control over their data without relying on third-party services. Deploy in minutes with Docker and start building your own productivity tools.
|
||||
|
||||
[](https://github.com/davidfolch/apocapoc-api/pkgs/container/apocapoc-api)
|
||||
[](https://golang.org/)
|
||||
[](LICENSE)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Docker Compose (Recommended)](#using-docker-compose-recommended)
|
||||
- [Binary Installation](#using-the-binary)
|
||||
- [Use Cases](#use-cases)
|
||||
- [API Documentation](#api-documentation)
|
||||
- [Development](#development)
|
||||
- [Architecture](#architecture)
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple habit types**: Boolean (check), Counter, Value
|
||||
- **Flexible scheduling**: Daily, Weekly, Monthly with specific days
|
||||
- **Carry-over support**: Choose if incomplete habits persist or expire
|
||||
- **Full history tracking**: Complete audit trail of all interactions
|
||||
- **Statistics**: Track streaks, completion rates, and progress
|
||||
- **Self-hosted first**: Easy deployment with SQLite
|
||||
- **Security**: JWT authentication, rate limiting on auth endpoints, optional email verification
|
||||
- **Email notifications**: Optional welcome emails and verification emails
|
||||
- **Registration control**: Open or closed registration modes
|
||||
- **API Documentation**: Interactive Swagger UI
|
||||
- **Multiple habit types**: Boolean (daily check-ins), Counter (track numbers), Value (measurements)
|
||||
- **Flexible scheduling**: Daily, Weekly, Monthly with custom day selection
|
||||
- **Statistics endpoints**: Streaks, completion rates, and progress tracking
|
||||
- **Complete history**: Full audit trail of all interactions
|
||||
- **Easy deployment**: Single Docker container or binary with embedded SQLite
|
||||
- **Security**: JWT authentication, rate limiting, optional email verification
|
||||
- **Registration modes**: Open or closed for controlled access
|
||||
- **Interactive docs**: Built-in Swagger UI for testing endpoints
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -60,87 +73,81 @@ volumes:
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
The API will be available at `http://localhost:8080`
|
||||
API available at `http://localhost:8080`
|
||||
|
||||
**Available image tags:**
|
||||
- `latest`: Latest stable release (recommended for production)
|
||||
- `1`, `1.0`, `1.0.0`: Specific version tags
|
||||
- `edge`: Latest development build from main branch (unstable)
|
||||
- `sha-abc123`: Specific commit (for debugging)
|
||||
**Image tags:**
|
||||
- `latest`: Stable release (recommended)
|
||||
- `1`, `1.0`, `1.0.0`: Specific versions
|
||||
- `edge`: Development build (unstable)
|
||||
|
||||
**Configuration options:**
|
||||
**Configuration:**
|
||||
|
||||
**Required:**
|
||||
- `JWT_SECRET`: **Required**. Use a long random string
|
||||
- `DB_PATH`: Database file path (default: `./data/apocapoc.db`)
|
||||
*Required:*
|
||||
- `JWT_SECRET`: Long random string (required)
|
||||
- `DB_PATH`: Database path (default: `./data/apocapoc.db`)
|
||||
|
||||
**Application:**
|
||||
*Application:*
|
||||
- `PORT`: HTTP port (default: `8080`)
|
||||
- `APP_URL`: Public URL for email links (e.g., `https://habits.yourdomain.com`)
|
||||
- `DEFAULT_TIMEZONE`: Timezone for date calculations (e.g., `UTC`, `Europe/Madrid`)
|
||||
- `APP_URL`: Public URL for email links
|
||||
- `DEFAULT_TIMEZONE`: e.g., `UTC`, `Europe/Madrid`
|
||||
|
||||
**Authentication:**
|
||||
- `JWT_EXPIRY`: Token expiration (e.g., `1h`, `24h`)
|
||||
- `REFRESH_TOKEN_EXPIRY`: Refresh token expiration (e.g., `7d`, `168h`)
|
||||
*Authentication:*
|
||||
- `JWT_EXPIRY`: e.g., `1h`, `24h`
|
||||
- `REFRESH_TOKEN_EXPIRY`: e.g., `7d`, `168h`
|
||||
- `REGISTRATION_MODE`: `open` or `closed`
|
||||
|
||||
**Registration:**
|
||||
- `REGISTRATION_MODE`: `open` (anyone can register) or `closed` (registration disabled)
|
||||
*Email (optional):*
|
||||
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`
|
||||
- `SUPPORT_EMAIL`: Default `contact@apocapoc.app`
|
||||
- `SEND_WELCOME_EMAIL`: `true`/`false`
|
||||
|
||||
**Email (optional - all or none):**
|
||||
- `SMTP_HOST`: SMTP server hostname
|
||||
- `SMTP_PORT`: SMTP port (`587` for STARTTLS, `465` for SSL)
|
||||
- `SMTP_USER`: SMTP username
|
||||
- `SMTP_PASSWORD`: SMTP password (use `$$` to escape `$` in passwords)
|
||||
- `SMTP_FROM`: From address for emails
|
||||
- `SUPPORT_EMAIL`: Support email shown in emails (default: `contact@apocapoc.app`)
|
||||
- `SEND_WELCOME_EMAIL`: Send welcome email after verification (`true`/`false`)
|
||||
|
||||
**Note:** If SMTP is not configured, email verification is skipped and users are auto-verified.
|
||||
Without SMTP config, users are auto-verified.
|
||||
|
||||
### Using the binary
|
||||
|
||||
1. Download the latest release from [GitHub Releases](https://github.com/davidfolch/apocapoc-api/releases)
|
||||
2. Extract the archive:
|
||||
```bash
|
||||
tar -xzf apocapoc-api_*_linux_amd64.tar.gz
|
||||
```
|
||||
3. Copy `.env.example` to `.env` and configure
|
||||
4. Run the binary:
|
||||
```bash
|
||||
./apocapoc-api
|
||||
```
|
||||
1. Download from [GitHub Releases](https://github.com/davidfolch/apocapoc-api/releases)
|
||||
2. Extract: `tar -xzf apocapoc-api_*_linux_amd64.tar.gz`
|
||||
3. Configure: `cp .env.example .env` (edit as needed)
|
||||
4. Run: `./apocapoc-api`
|
||||
|
||||
The API will be available at `http://localhost:8080`
|
||||
API available at `http://localhost:8080`
|
||||
|
||||
**Note:** Linux binaries only (amd64 and arm64). For other platforms, use Docker.
|
||||
*Note: Linux only (amd64/arm64). Use Docker for other platforms.*
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.23+
|
||||
- SQLite
|
||||
- Docker (optional)
|
||||
|
||||
### Running locally with Docker (Recommended)
|
||||
**Prerequisites:** Go 1.23+, SQLite, Docker (optional)
|
||||
|
||||
**With Docker:**
|
||||
```bash
|
||||
cp docker-compose.example.yml docker-compose.yml
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
The API will be available at `http://localhost:8080`
|
||||
|
||||
### Running locally with Go
|
||||
|
||||
**With Go:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
go run cmd/api/main.go
|
||||
```
|
||||
|
||||
API runs on `http://localhost:8080`
|
||||
|
||||
## Use Cases
|
||||
|
||||
Perfect for:
|
||||
|
||||
- **Custom mobile/web apps**: Build your own interface without backend complexity
|
||||
- **Personal dashboards**: Integrate with Grafana, Nextcloud, or Home Assistant
|
||||
- **Automation workflows**: Connect to n8n, Zapier, or custom scripts
|
||||
- **Privacy-focused teams**: Keep sensitive productivity data on your infrastructure
|
||||
- **API learning projects**: Clean architecture with real-world examples
|
||||
- **Offline-first tools**: SQLite backend works without cloud dependencies
|
||||
|
||||
## API Documentation
|
||||
|
||||
Once running, visit `http://localhost:8080/api/v1/docs` for interactive Swagger documentation.
|
||||
Access the interactive Swagger UI at `http://localhost:8080/api/v1/docs`
|
||||
|
||||
Includes endpoint reference, schemas, authentication examples, and live testing.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -156,6 +163,10 @@ This project follows hexagonal (ports & adapters) architecture:
|
||||
- 📧 Email: contact@apocapoc.app
|
||||
- 🐛 Issues: [GitHub Issues](https://github.com/davidfolch/apocapoc-api/issues)
|
||||
|
||||
## Keywords
|
||||
|
||||
`habit-tracker` `habit-tracking` `rest-api` `self-hosted` `golang` `api` `habits` `productivity` `docker` `sqlite` `hexagonal-architecture` `clean-architecture` `habit-tracker-api` `self-hosted-api` `personal-analytics` `privacy` `open-source`
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type mockDeleteUserRepo struct {
|
||||
findByIDFunc func(ctx context.Context, id string) (*entities.User, error)
|
||||
deleteFunc func(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) {
|
||||
if m.findByIDFunc != nil {
|
||||
return m.findByIDFunc(ctx, id)
|
||||
}
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) Create(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) Update(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) Delete(ctx context.Context, id string) error {
|
||||
if m.deleteFunc != nil {
|
||||
return m.deleteFunc(ctx, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDeleteUserHandler_Success(t *testing.T) {
|
||||
var deletedID string
|
||||
|
||||
repo := &mockDeleteUserRepo{
|
||||
findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) {
|
||||
user := entities.NewUser("test@example.com", "hashedPassword", "UTC")
|
||||
user.ID = id
|
||||
return user, nil
|
||||
},
|
||||
deleteFunc: func(ctx context.Context, id string) error {
|
||||
deletedID = id
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewDeleteUserHandler(repo)
|
||||
|
||||
cmd := DeleteUserCommand{
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if deletedID != "user-123" {
|
||||
t.Errorf("deletedID = %v, want %v", deletedID, "user-123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUserHandler_EmptyUserID(t *testing.T) {
|
||||
repo := &mockDeleteUserRepo{}
|
||||
handler := NewDeleteUserHandler(repo)
|
||||
|
||||
cmd := DeleteUserCommand{
|
||||
UserID: "",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUserHandler_UserNotFound(t *testing.T) {
|
||||
repo := &mockDeleteUserRepo{
|
||||
findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewDeleteUserHandler(repo)
|
||||
|
||||
cmd := DeleteUserCommand{
|
||||
UserID: "non-existent-user",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrNotFound {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUserHandler_DeleteError(t *testing.T) {
|
||||
customError := errors.ErrNotFound
|
||||
|
||||
repo := &mockDeleteUserRepo{
|
||||
findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) {
|
||||
user := entities.NewUser("test@example.com", "hashedPassword", "UTC")
|
||||
user.ID = id
|
||||
return user, nil
|
||||
},
|
||||
deleteFunc: func(ctx context.Context, id string) error {
|
||||
return customError
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewDeleteUserHandler(repo)
|
||||
|
||||
cmd := DeleteUserCommand{
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != customError {
|
||||
t.Errorf("Handle() error = %v, want %v", err, customError)
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,10 @@ type RequestPasswordResetCommand struct {
|
||||
}
|
||||
|
||||
type RequestPasswordResetHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
passwordResetTokenRepo repositories.PasswordResetTokenRepository
|
||||
emailService services.EmailService
|
||||
appURL string
|
||||
userRepo repositories.UserRepository
|
||||
passwordResetTokenRepo repositories.PasswordResetTokenRepository
|
||||
emailService services.EmailService
|
||||
appURL string
|
||||
}
|
||||
|
||||
func NewRequestPasswordResetHandler(
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type mockResetPasswordUserRepo struct {
|
||||
findByIDFunc func(ctx context.Context, id string) (*entities.User, error)
|
||||
updateFunc func(ctx context.Context, user *entities.User) error
|
||||
users map[string]*entities.User
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) {
|
||||
if m.findByIDFunc != nil {
|
||||
return m.findByIDFunc(ctx, id)
|
||||
}
|
||||
if user, ok := m.users[id]; ok {
|
||||
return user, nil
|
||||
}
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) Update(ctx context.Context, user *entities.User) error {
|
||||
if m.updateFunc != nil {
|
||||
return m.updateFunc(ctx, user)
|
||||
}
|
||||
m.users[user.ID] = user
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) Create(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockPasswordResetTokenRepo struct {
|
||||
findByTokenFunc func(ctx context.Context, token string) (*entities.PasswordResetToken, error)
|
||||
updateFunc func(ctx context.Context, token *entities.PasswordResetToken) error
|
||||
tokens map[string]*entities.PasswordResetToken
|
||||
}
|
||||
|
||||
func (m *mockPasswordResetTokenRepo) Create(ctx context.Context, token *entities.PasswordResetToken) error {
|
||||
m.tokens[token.Token] = token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPasswordResetTokenRepo) FindByToken(ctx context.Context, token string) (*entities.PasswordResetToken, error) {
|
||||
if m.findByTokenFunc != nil {
|
||||
return m.findByTokenFunc(ctx, token)
|
||||
}
|
||||
if t, ok := m.tokens[token]; ok {
|
||||
return t, nil
|
||||
}
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockPasswordResetTokenRepo) Update(ctx context.Context, token *entities.PasswordResetToken) error {
|
||||
if m.updateFunc != nil {
|
||||
return m.updateFunc(ctx, token)
|
||||
}
|
||||
m.tokens[token.Token] = token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPasswordResetTokenRepo) DeleteExpired(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockResetPasswordHasher struct {
|
||||
hashFunc func(password string) (string, error)
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordHasher) Hash(password string) (string, error) {
|
||||
if m.hashFunc != nil {
|
||||
return m.hashFunc(password)
|
||||
}
|
||||
return "hashed_" + password, nil
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordHasher) Compare(hashedPassword, password string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_Success(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "old_hash", "UTC")
|
||||
user.ID = "user-123"
|
||||
|
||||
resetToken := entities.NewPasswordResetToken(
|
||||
user.ID,
|
||||
"reset-token",
|
||||
time.Now().Add(1*time.Hour),
|
||||
)
|
||||
|
||||
var updatedUser *entities.User
|
||||
var updatedToken *entities.PasswordResetToken
|
||||
|
||||
userRepo := &mockResetPasswordUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
user.ID: user,
|
||||
},
|
||||
updateFunc: func(ctx context.Context, u *entities.User) error {
|
||||
updatedUser = u
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
tokenRepo := &mockPasswordResetTokenRepo{
|
||||
tokens: map[string]*entities.PasswordResetToken{
|
||||
resetToken.Token: resetToken,
|
||||
},
|
||||
updateFunc: func(ctx context.Context, t *entities.PasswordResetToken) error {
|
||||
updatedToken = t
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
hasher := &mockResetPasswordHasher{}
|
||||
|
||||
handler := NewResetPasswordHandler(userRepo, tokenRepo, hasher)
|
||||
|
||||
cmd := ResetPasswordCommand{
|
||||
Token: "reset-token",
|
||||
NewPassword: "NewP@ssw0rd123",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if updatedUser == nil {
|
||||
t.Fatal("User was not updated")
|
||||
}
|
||||
|
||||
if updatedUser.PasswordHash != "hashed_NewP@ssw0rd123" {
|
||||
t.Errorf("PasswordHash = %v, want %v", updatedUser.PasswordHash, "hashed_NewP@ssw0rd123")
|
||||
}
|
||||
|
||||
if updatedToken == nil {
|
||||
t.Fatal("Token was not updated")
|
||||
}
|
||||
|
||||
if !updatedToken.IsUsed() {
|
||||
t.Error("Token should be marked as used")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_EmptyToken(t *testing.T) {
|
||||
handler := NewResetPasswordHandler(
|
||||
&mockResetPasswordUserRepo{users: make(map[string]*entities.User)},
|
||||
&mockPasswordResetTokenRepo{tokens: make(map[string]*entities.PasswordResetToken)},
|
||||
&mockResetPasswordHasher{},
|
||||
)
|
||||
|
||||
cmd := ResetPasswordCommand{
|
||||
Token: "",
|
||||
NewPassword: "NewP@ssw0rd123",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_EmptyPassword(t *testing.T) {
|
||||
handler := NewResetPasswordHandler(
|
||||
&mockResetPasswordUserRepo{users: make(map[string]*entities.User)},
|
||||
&mockPasswordResetTokenRepo{tokens: make(map[string]*entities.PasswordResetToken)},
|
||||
&mockResetPasswordHasher{},
|
||||
)
|
||||
|
||||
cmd := ResetPasswordCommand{
|
||||
Token: "reset-token",
|
||||
NewPassword: "",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_InvalidPassword(t *testing.T) {
|
||||
handler := NewResetPasswordHandler(
|
||||
&mockResetPasswordUserRepo{users: make(map[string]*entities.User)},
|
||||
&mockPasswordResetTokenRepo{tokens: make(map[string]*entities.PasswordResetToken)},
|
||||
&mockResetPasswordHasher{},
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
}{
|
||||
{"too short", "Short1!"},
|
||||
{"no uppercase", "password123!"},
|
||||
{"no lowercase", "PASSWORD123!"},
|
||||
{"no digit", "Password!"},
|
||||
{"no special char", "Password123"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := ResetPasswordCommand{
|
||||
Token: "reset-token",
|
||||
NewPassword: tt.password,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_TokenNotFound(t *testing.T) {
|
||||
handler := NewResetPasswordHandler(
|
||||
&mockResetPasswordUserRepo{users: make(map[string]*entities.User)},
|
||||
&mockPasswordResetTokenRepo{tokens: make(map[string]*entities.PasswordResetToken)},
|
||||
&mockResetPasswordHasher{},
|
||||
)
|
||||
|
||||
cmd := ResetPasswordCommand{
|
||||
Token: "non-existent-token",
|
||||
NewPassword: "NewP@ssw0rd123",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_ExpiredToken(t *testing.T) {
|
||||
resetToken := entities.NewPasswordResetToken(
|
||||
"user-123",
|
||||
"expired-token",
|
||||
time.Now().Add(-1*time.Hour),
|
||||
)
|
||||
|
||||
tokenRepo := &mockPasswordResetTokenRepo{
|
||||
tokens: map[string]*entities.PasswordResetToken{
|
||||
resetToken.Token: resetToken,
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewResetPasswordHandler(
|
||||
&mockResetPasswordUserRepo{users: make(map[string]*entities.User)},
|
||||
tokenRepo,
|
||||
&mockResetPasswordHasher{},
|
||||
)
|
||||
|
||||
cmd := ResetPasswordCommand{
|
||||
Token: "expired-token",
|
||||
NewPassword: "NewP@ssw0rd123",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_UsedToken(t *testing.T) {
|
||||
resetToken := entities.NewPasswordResetToken(
|
||||
"user-123",
|
||||
"used-token",
|
||||
time.Now().Add(1*time.Hour),
|
||||
)
|
||||
resetToken.MarkAsUsed()
|
||||
|
||||
tokenRepo := &mockPasswordResetTokenRepo{
|
||||
tokens: map[string]*entities.PasswordResetToken{
|
||||
resetToken.Token: resetToken,
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewResetPasswordHandler(
|
||||
&mockResetPasswordUserRepo{users: make(map[string]*entities.User)},
|
||||
tokenRepo,
|
||||
&mockResetPasswordHasher{},
|
||||
)
|
||||
|
||||
cmd := ResetPasswordCommand{
|
||||
Token: "used-token",
|
||||
NewPassword: "NewP@ssw0rd123",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_UserNotFound(t *testing.T) {
|
||||
resetToken := entities.NewPasswordResetToken(
|
||||
"non-existent-user",
|
||||
"reset-token",
|
||||
time.Now().Add(1*time.Hour),
|
||||
)
|
||||
|
||||
tokenRepo := &mockPasswordResetTokenRepo{
|
||||
tokens: map[string]*entities.PasswordResetToken{
|
||||
resetToken.Token: resetToken,
|
||||
},
|
||||
}
|
||||
|
||||
userRepo := &mockResetPasswordUserRepo{
|
||||
users: make(map[string]*entities.User),
|
||||
}
|
||||
|
||||
handler := NewResetPasswordHandler(userRepo, tokenRepo, &mockResetPasswordHasher{})
|
||||
|
||||
cmd := ResetPasswordCommand{
|
||||
Token: "reset-token",
|
||||
NewPassword: "NewP@ssw0rd123",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrNotFound {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_HashingError(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "old_hash", "UTC")
|
||||
user.ID = "user-123"
|
||||
|
||||
resetToken := entities.NewPasswordResetToken(
|
||||
user.ID,
|
||||
"reset-token",
|
||||
time.Now().Add(1*time.Hour),
|
||||
)
|
||||
|
||||
userRepo := &mockResetPasswordUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
user.ID: user,
|
||||
},
|
||||
}
|
||||
|
||||
tokenRepo := &mockPasswordResetTokenRepo{
|
||||
tokens: map[string]*entities.PasswordResetToken{
|
||||
resetToken.Token: resetToken,
|
||||
},
|
||||
}
|
||||
|
||||
hasher := &mockResetPasswordHasher{
|
||||
hashFunc: func(password string) (string, error) {
|
||||
return "", errors.ErrInvalidInput
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewResetPasswordHandler(userRepo, tokenRepo, hasher)
|
||||
|
||||
cmd := ResetPasswordCommand{
|
||||
Token: "reset-token",
|
||||
NewPassword: "NewP@ssw0rd123",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err == nil {
|
||||
t.Fatal("Handle() expected error but got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type mockRefreshTokenRepo struct {
|
||||
tokens map[string]*entities.RefreshToken
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) Create(ctx context.Context, token *entities.RefreshToken) error {
|
||||
m.tokens[token.Token] = token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) FindByToken(ctx context.Context, token string) (*entities.RefreshToken, error) {
|
||||
if t, ok := m.tokens[token]; ok {
|
||||
return t, nil
|
||||
}
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.RefreshToken, error) {
|
||||
var tokens []*entities.RefreshToken
|
||||
for _, t := range m.tokens {
|
||||
if t.UserID == userID {
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) RevokeByToken(ctx context.Context, token string) error {
|
||||
if t, ok := m.tokens[token]; ok {
|
||||
t.Revoke()
|
||||
return nil
|
||||
}
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) RevokeAllByUserID(ctx context.Context, userID string) error {
|
||||
for _, t := range m.tokens {
|
||||
if t.UserID == userID {
|
||||
t.Revoke()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) DeleteExpired(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRevokeTokenHandler_Handle(t *testing.T) {
|
||||
repo := &mockRefreshTokenRepo{
|
||||
tokens: make(map[string]*entities.RefreshToken),
|
||||
}
|
||||
|
||||
handler := NewRevokeTokenHandler(repo)
|
||||
|
||||
token := entities.NewRefreshToken("user-123", "valid-token", time.Now().Add(24*time.Hour))
|
||||
repo.Create(context.Background(), token)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd RevokeTokenCommand
|
||||
expectError bool
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
name: "revoke valid token",
|
||||
cmd: RevokeTokenCommand{
|
||||
RefreshToken: "valid-token",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "revoke empty token",
|
||||
cmd: RevokeTokenCommand{
|
||||
RefreshToken: "",
|
||||
},
|
||||
expectError: true,
|
||||
expectedErr: errors.ErrInvalidInput,
|
||||
},
|
||||
{
|
||||
name: "revoke non-existent token",
|
||||
cmd: RevokeTokenCommand{
|
||||
RefreshToken: "non-existent-token",
|
||||
},
|
||||
expectError: true,
|
||||
expectedErr: errors.ErrNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := handler.Handle(context.Background(), tt.cmd)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatal("Handle() expected error but got nil")
|
||||
}
|
||||
if tt.expectedErr != nil && err != tt.expectedErr {
|
||||
t.Errorf("Handle() error = %v, want %v", err, tt.expectedErr)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeAllTokensHandler_Handle(t *testing.T) {
|
||||
repo := &mockRefreshTokenRepo{
|
||||
tokens: make(map[string]*entities.RefreshToken),
|
||||
}
|
||||
|
||||
handler := NewRevokeAllTokensHandler(repo)
|
||||
|
||||
token1 := entities.NewRefreshToken("user-123", "token-1", time.Now().Add(24*time.Hour))
|
||||
token2 := entities.NewRefreshToken("user-123", "token-2", time.Now().Add(24*time.Hour))
|
||||
token3 := entities.NewRefreshToken("user-456", "token-3", time.Now().Add(24*time.Hour))
|
||||
|
||||
repo.Create(context.Background(), token1)
|
||||
repo.Create(context.Background(), token2)
|
||||
repo.Create(context.Background(), token3)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd RevokeAllTokensCommand
|
||||
expectError bool
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
name: "revoke all tokens for user",
|
||||
cmd: RevokeAllTokensCommand{
|
||||
UserID: "user-123",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "revoke with empty user ID",
|
||||
cmd: RevokeAllTokensCommand{
|
||||
UserID: "",
|
||||
},
|
||||
expectError: true,
|
||||
expectedErr: errors.ErrInvalidInput,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := handler.Handle(context.Background(), tt.cmd)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatal("Handle() expected error but got nil")
|
||||
}
|
||||
if tt.expectedErr != nil && err != tt.expectedErr {
|
||||
t.Errorf("Handle() error = %v, want %v", err, tt.expectedErr)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if !tt.expectError && tt.cmd.UserID == "user-123" {
|
||||
if repo.tokens["token-1"].RevokedAt == nil {
|
||||
t.Error("token-1 should be revoked")
|
||||
}
|
||||
if repo.tokens["token-2"].RevokedAt == nil {
|
||||
t.Error("token-2 should be revoked")
|
||||
}
|
||||
if repo.tokens["token-3"].RevokedAt != nil {
|
||||
t.Error("token-3 should not be revoked")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type mockVerifyEmailUserRepo struct {
|
||||
findByVerificationTokenFunc func(ctx context.Context, token string) (*entities.User, error)
|
||||
updateFunc func(ctx context.Context, user *entities.User) error
|
||||
users map[string]*entities.User
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
|
||||
if m.findByVerificationTokenFunc != nil {
|
||||
return m.findByVerificationTokenFunc(ctx, token)
|
||||
}
|
||||
if user, ok := m.users[token]; ok {
|
||||
return user, nil
|
||||
}
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) Update(ctx context.Context, user *entities.User) error {
|
||||
if m.updateFunc != nil {
|
||||
return m.updateFunc(ctx, user)
|
||||
}
|
||||
m.users[user.Email] = user
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) Create(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockEmailService struct {
|
||||
sendFunc func(message services.EmailMessage) error
|
||||
sentMessages []services.EmailMessage
|
||||
}
|
||||
|
||||
func (m *mockEmailService) Send(message services.EmailMessage) error {
|
||||
m.sentMessages = append(m.sentMessages, message)
|
||||
if m.sendFunc != nil {
|
||||
return m.sendFunc(message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_Success(t *testing.T) {
|
||||
token := "valid-token"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
|
||||
user := entities.NewUser("test@example.com", "hashedPassword", "UTC")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = &expiry
|
||||
|
||||
var updatedUser *entities.User
|
||||
repo := &mockVerifyEmailUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
token: user,
|
||||
},
|
||||
updateFunc: func(ctx context.Context, u *entities.User) error {
|
||||
updatedUser = u
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewVerifyEmailHandler(repo, nil, false)
|
||||
|
||||
cmd := VerifyEmailCommand{
|
||||
Token: token,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if updatedUser == nil {
|
||||
t.Fatal("User was not updated")
|
||||
}
|
||||
|
||||
if !updatedUser.EmailVerified {
|
||||
t.Error("EmailVerified should be true")
|
||||
}
|
||||
|
||||
if updatedUser.EmailVerificationToken != nil {
|
||||
t.Error("EmailVerificationToken should be nil after verification")
|
||||
}
|
||||
|
||||
if updatedUser.EmailVerificationExpiry != nil {
|
||||
t.Error("EmailVerificationExpiry should be nil after verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_EmptyToken(t *testing.T) {
|
||||
repo := &mockVerifyEmailUserRepo{
|
||||
users: make(map[string]*entities.User),
|
||||
}
|
||||
handler := NewVerifyEmailHandler(repo, nil, false)
|
||||
|
||||
cmd := VerifyEmailCommand{
|
||||
Token: "",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_TokenNotFound(t *testing.T) {
|
||||
repo := &mockVerifyEmailUserRepo{
|
||||
users: make(map[string]*entities.User),
|
||||
}
|
||||
handler := NewVerifyEmailHandler(repo, nil, false)
|
||||
|
||||
cmd := VerifyEmailCommand{
|
||||
Token: "non-existent-token",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_AlreadyVerified(t *testing.T) {
|
||||
token := "valid-token"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
|
||||
user := entities.NewUser("test@example.com", "hashedPassword", "UTC")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = true
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = &expiry
|
||||
|
||||
repo := &mockVerifyEmailUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
token: user,
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewVerifyEmailHandler(repo, nil, false)
|
||||
|
||||
cmd := VerifyEmailCommand{
|
||||
Token: token,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrAlreadyExists {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrAlreadyExists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_ExpiredToken(t *testing.T) {
|
||||
token := "expired-token"
|
||||
expiry := time.Now().Add(-1 * time.Hour)
|
||||
|
||||
user := entities.NewUser("test@example.com", "hashedPassword", "UTC")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = &expiry
|
||||
|
||||
repo := &mockVerifyEmailUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
token: user,
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewVerifyEmailHandler(repo, nil, false)
|
||||
|
||||
cmd := VerifyEmailCommand{
|
||||
Token: token,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_NilExpiry(t *testing.T) {
|
||||
token := "valid-token"
|
||||
|
||||
user := entities.NewUser("test@example.com", "hashedPassword", "UTC")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = nil
|
||||
|
||||
repo := &mockVerifyEmailUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
token: user,
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewVerifyEmailHandler(repo, nil, false)
|
||||
|
||||
cmd := VerifyEmailCommand{
|
||||
Token: token,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_WithWelcomeEmail(t *testing.T) {
|
||||
token := "valid-token"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
|
||||
user := entities.NewUser("test@example.com", "hashedPassword", "UTC")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = &expiry
|
||||
|
||||
repo := &mockVerifyEmailUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
token: user,
|
||||
},
|
||||
}
|
||||
|
||||
emailService := &mockEmailService{}
|
||||
handler := NewVerifyEmailHandler(repo, emailService, true)
|
||||
|
||||
cmd := VerifyEmailCommand{
|
||||
Token: token,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if len(emailService.sentMessages) != 1 {
|
||||
t.Fatalf("Expected 1 email sent, got %d", len(emailService.sentMessages))
|
||||
}
|
||||
|
||||
sentEmail := emailService.sentMessages[0]
|
||||
if sentEmail.To != "test@example.com" {
|
||||
t.Errorf("Email To = %v, want %v", sentEmail.To, "test@example.com")
|
||||
}
|
||||
|
||||
if sentEmail.Subject != "Welcome to Apocapoc!" {
|
||||
t.Errorf("Email Subject = %v, want %v", sentEmail.Subject, "Welcome to Apocapoc!")
|
||||
}
|
||||
|
||||
if !sentEmail.IsHTML {
|
||||
t.Error("Email should be HTML")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_WithoutWelcomeEmail(t *testing.T) {
|
||||
token := "valid-token"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
|
||||
user := entities.NewUser("test@example.com", "hashedPassword", "UTC")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = &expiry
|
||||
|
||||
repo := &mockVerifyEmailUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
token: user,
|
||||
},
|
||||
}
|
||||
|
||||
emailService := &mockEmailService{}
|
||||
handler := NewVerifyEmailHandler(repo, emailService, false)
|
||||
|
||||
cmd := VerifyEmailCommand{
|
||||
Token: token,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if len(emailService.sentMessages) != 0 {
|
||||
t.Errorf("Expected 0 emails sent, got %d", len(emailService.sentMessages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_UpdateError(t *testing.T) {
|
||||
token := "valid-token"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
|
||||
user := entities.NewUser("test@example.com", "hashedPassword", "UTC")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = &expiry
|
||||
|
||||
repo := &mockVerifyEmailUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
token: user,
|
||||
},
|
||||
updateFunc: func(ctx context.Context, u *entities.User) error {
|
||||
return errors.ErrNotFound
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewVerifyEmailHandler(repo, nil, false)
|
||||
|
||||
cmd := VerifyEmailCommand{
|
||||
Token: token,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err == nil {
|
||||
t.Fatal("Handle() expected error but got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type mockLoginUserRepo struct {
|
||||
findByEmailFunc func(ctx context.Context, email string) (*entities.User, error)
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||
if m.findByEmailFunc != nil {
|
||||
return m.findByEmailFunc(ctx, email)
|
||||
}
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) Create(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) Update(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockLoginPasswordHasher struct {
|
||||
compareFunc func(hashedPassword, password string) error
|
||||
}
|
||||
|
||||
func (m *mockLoginPasswordHasher) Hash(password string) (string, error) {
|
||||
return "hashed_" + password, nil
|
||||
}
|
||||
|
||||
func (m *mockLoginPasswordHasher) Compare(hashedPassword, password string) error {
|
||||
if m.compareFunc != nil {
|
||||
return m.compareFunc(hashedPassword, password)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestLoginUserHandler_Success(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hashed_password", "UTC")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = true
|
||||
|
||||
repo := &mockLoginUserRepo{
|
||||
findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) {
|
||||
return user, nil
|
||||
},
|
||||
}
|
||||
|
||||
hasher := &mockLoginPasswordHasher{
|
||||
compareFunc: func(hashedPassword, password string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewLoginUserHandler(repo, hasher)
|
||||
|
||||
query := LoginUserQuery{
|
||||
Email: "test@example.com",
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if result.UserID != "user-123" {
|
||||
t.Errorf("UserID = %v, want %v", result.UserID, "user-123")
|
||||
}
|
||||
|
||||
if result.Email != "test@example.com" {
|
||||
t.Errorf("Email = %v, want %v", result.Email, "test@example.com")
|
||||
}
|
||||
|
||||
if result.Timezone != "UTC" {
|
||||
t.Errorf("Timezone = %v, want %v", result.Timezone, "UTC")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginUserHandler_EmptyEmail(t *testing.T) {
|
||||
repo := &mockLoginUserRepo{}
|
||||
hasher := &mockLoginPasswordHasher{}
|
||||
handler := NewLoginUserHandler(repo, hasher)
|
||||
|
||||
query := LoginUserQuery{
|
||||
Email: "",
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), query)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginUserHandler_EmptyPassword(t *testing.T) {
|
||||
repo := &mockLoginUserRepo{}
|
||||
hasher := &mockLoginPasswordHasher{}
|
||||
handler := NewLoginUserHandler(repo, hasher)
|
||||
|
||||
query := LoginUserQuery{
|
||||
Email: "test@example.com",
|
||||
Password: "",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), query)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginUserHandler_UserNotFound(t *testing.T) {
|
||||
repo := &mockLoginUserRepo{
|
||||
findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
},
|
||||
}
|
||||
|
||||
hasher := &mockLoginPasswordHasher{}
|
||||
handler := NewLoginUserHandler(repo, hasher)
|
||||
|
||||
query := LoginUserQuery{
|
||||
Email: "nonexistent@example.com",
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), query)
|
||||
if err != errors.ErrNotFound {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginUserHandler_InvalidPassword(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hashed_password", "UTC")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = true
|
||||
|
||||
repo := &mockLoginUserRepo{
|
||||
findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) {
|
||||
return user, nil
|
||||
},
|
||||
}
|
||||
|
||||
hasher := &mockLoginPasswordHasher{
|
||||
compareFunc: func(hashedPassword, password string) error {
|
||||
return errors.ErrInvalidInput
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewLoginUserHandler(repo, hasher)
|
||||
|
||||
query := LoginUserQuery{
|
||||
Email: "test@example.com",
|
||||
Password: "wrongpassword",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), query)
|
||||
if err != errors.ErrNotFound {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginUserHandler_EmailNotVerified(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hashed_password", "UTC")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
|
||||
repo := &mockLoginUserRepo{
|
||||
findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) {
|
||||
return user, nil
|
||||
},
|
||||
}
|
||||
|
||||
hasher := &mockLoginPasswordHasher{
|
||||
compareFunc: func(hashedPassword, password string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewLoginUserHandler(repo, hasher)
|
||||
|
||||
query := LoginUserQuery{
|
||||
Email: "test@example.com",
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), query)
|
||||
if err != errors.ErrEmailNotVerified {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrEmailNotVerified)
|
||||
}
|
||||
}
|
||||
@@ -182,3 +182,87 @@ func TestRefreshTokenHandler_EmptyToken(t *testing.T) {
|
||||
t.Errorf("Expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRefreshToken(t *testing.T) {
|
||||
token1, err := GenerateRefreshToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if token1 == "" {
|
||||
t.Fatal("GenerateRefreshToken() returned empty token")
|
||||
}
|
||||
|
||||
token2, err := GenerateRefreshToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if token1 == token2 {
|
||||
t.Error("GenerateRefreshToken() generated identical tokens")
|
||||
}
|
||||
|
||||
if len(token1) < 20 {
|
||||
t.Errorf("GenerateRefreshToken() token too short: %d characters", len(token1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRefreshToken(t *testing.T) {
|
||||
userID := "user-123"
|
||||
expiryDuration := 7 * 24 * time.Hour
|
||||
|
||||
token, err := CreateRefreshToken(userID, expiryDuration)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRefreshToken() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if token == nil {
|
||||
t.Fatal("CreateRefreshToken() returned nil")
|
||||
}
|
||||
|
||||
if token.UserID != userID {
|
||||
t.Errorf("UserID = %v, want %v", token.UserID, userID)
|
||||
}
|
||||
|
||||
if token.Token == "" {
|
||||
t.Error("Token is empty")
|
||||
}
|
||||
|
||||
if token.ExpiresAt.IsZero() {
|
||||
t.Error("ExpiresAt is zero")
|
||||
}
|
||||
|
||||
if token.CreatedAt.IsZero() {
|
||||
t.Error("CreatedAt is zero")
|
||||
}
|
||||
|
||||
expectedExpiry := time.Now().Add(expiryDuration)
|
||||
diff := token.ExpiresAt.Sub(expectedExpiry)
|
||||
if diff > time.Second || diff < -time.Second {
|
||||
t.Errorf("ExpiresAt difference too large: %v", diff)
|
||||
}
|
||||
|
||||
if !token.IsValid() {
|
||||
t.Error("Token should be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRefreshToken_MultipleCalls(t *testing.T) {
|
||||
token1, err := CreateRefreshToken("user-1", 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRefreshToken(1) unexpected error = %v", err)
|
||||
}
|
||||
|
||||
token2, err := CreateRefreshToken("user-2", 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRefreshToken(2) unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if token1.Token == token2.Token {
|
||||
t.Error("CreateRefreshToken() generated identical tokens for different users")
|
||||
}
|
||||
|
||||
if token1.UserID == token2.UserID {
|
||||
t.Error("UserIDs should be different")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewPasswordResetToken(t *testing.T) {
|
||||
userID := "user-123"
|
||||
token := "reset-token-abc"
|
||||
expiresAt := time.Now().Add(1 * time.Hour)
|
||||
|
||||
prt := NewPasswordResetToken(userID, token, expiresAt)
|
||||
|
||||
if prt == nil {
|
||||
t.Fatal("NewPasswordResetToken() returned nil")
|
||||
}
|
||||
|
||||
if prt.ID == "" {
|
||||
t.Error("ID is empty")
|
||||
}
|
||||
|
||||
if prt.UserID != userID {
|
||||
t.Errorf("UserID = %v, want %v", prt.UserID, userID)
|
||||
}
|
||||
|
||||
if prt.Token != token {
|
||||
t.Errorf("Token = %v, want %v", prt.Token, token)
|
||||
}
|
||||
|
||||
if !prt.ExpiresAt.Equal(expiresAt) {
|
||||
t.Errorf("ExpiresAt = %v, want %v", prt.ExpiresAt, expiresAt)
|
||||
}
|
||||
|
||||
if prt.UsedAt != nil {
|
||||
t.Error("UsedAt should be nil for new token")
|
||||
}
|
||||
|
||||
if prt.CreatedAt.IsZero() {
|
||||
t.Error("CreatedAt is zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetToken_IsExpired(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expiresAt time.Time
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "future expiry",
|
||||
expiresAt: time.Now().Add(1 * time.Hour),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "past expiry",
|
||||
expiresAt: time.Now().Add(-1 * time.Hour),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "expires in 1 second",
|
||||
expiresAt: time.Now().Add(1 * time.Second),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
prt := NewPasswordResetToken("user-123", "token", tt.expiresAt)
|
||||
got := prt.IsExpired()
|
||||
if got != tt.want {
|
||||
t.Errorf("IsExpired() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetToken_IsUsed(t *testing.T) {
|
||||
prt := NewPasswordResetToken("user-123", "token", time.Now().Add(1*time.Hour))
|
||||
|
||||
if prt.IsUsed() {
|
||||
t.Error("IsUsed() should return false for new token")
|
||||
}
|
||||
|
||||
prt.MarkAsUsed()
|
||||
|
||||
if !prt.IsUsed() {
|
||||
t.Error("IsUsed() should return true after MarkAsUsed()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetToken_MarkAsUsed(t *testing.T) {
|
||||
prt := NewPasswordResetToken("user-123", "token", time.Now().Add(1*time.Hour))
|
||||
|
||||
if prt.UsedAt != nil {
|
||||
t.Error("UsedAt should be nil before MarkAsUsed()")
|
||||
}
|
||||
|
||||
prt.MarkAsUsed()
|
||||
|
||||
if prt.UsedAt == nil {
|
||||
t.Fatal("UsedAt should not be nil after MarkAsUsed()")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
diff := now.Sub(*prt.UsedAt)
|
||||
if diff > time.Second || diff < 0 {
|
||||
t.Errorf("UsedAt difference too large: %v", diff)
|
||||
}
|
||||
|
||||
firstUsedAt := prt.UsedAt
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
prt.MarkAsUsed()
|
||||
|
||||
if prt.UsedAt == firstUsedAt {
|
||||
t.Error("MarkAsUsed() should update UsedAt on subsequent calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetToken_MultipleTokens(t *testing.T) {
|
||||
token1 := NewPasswordResetToken("user-1", "token-1", time.Now().Add(1*time.Hour))
|
||||
token2 := NewPasswordResetToken("user-2", "token-2", time.Now().Add(1*time.Hour))
|
||||
|
||||
if token1.ID == token2.ID {
|
||||
t.Error("NewPasswordResetToken() generated identical IDs")
|
||||
}
|
||||
|
||||
if token1.UserID == token2.UserID {
|
||||
t.Error("UserIDs should be different")
|
||||
}
|
||||
|
||||
if token1.Token == token2.Token {
|
||||
t.Error("Tokens should be different")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewRefreshToken(t *testing.T) {
|
||||
userID := "user-123"
|
||||
token := "refresh-token-abc"
|
||||
expiresAt := time.Now().Add(7 * 24 * time.Hour)
|
||||
|
||||
rt := NewRefreshToken(userID, token, expiresAt)
|
||||
|
||||
if rt == nil {
|
||||
t.Fatal("NewRefreshToken() returned nil")
|
||||
}
|
||||
|
||||
if rt.UserID != userID {
|
||||
t.Errorf("UserID = %v, want %v", rt.UserID, userID)
|
||||
}
|
||||
|
||||
if rt.Token != token {
|
||||
t.Errorf("Token = %v, want %v", rt.Token, token)
|
||||
}
|
||||
|
||||
if !rt.ExpiresAt.Equal(expiresAt) {
|
||||
t.Errorf("ExpiresAt = %v, want %v", rt.ExpiresAt, expiresAt)
|
||||
}
|
||||
|
||||
if rt.RevokedAt != nil {
|
||||
t.Error("RevokedAt should be nil for new token")
|
||||
}
|
||||
|
||||
if rt.CreatedAt.IsZero() {
|
||||
t.Error("CreatedAt is zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshToken_IsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expiresAt time.Time
|
||||
revoked bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "valid token",
|
||||
expiresAt: time.Now().Add(24 * time.Hour),
|
||||
revoked: false,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "expired token",
|
||||
expiresAt: time.Now().Add(-1 * time.Hour),
|
||||
revoked: false,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "revoked token",
|
||||
expiresAt: time.Now().Add(24 * time.Hour),
|
||||
revoked: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "expired and revoked",
|
||||
expiresAt: time.Now().Add(-1 * time.Hour),
|
||||
revoked: true,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rt := NewRefreshToken("user-123", "token", tt.expiresAt)
|
||||
if tt.revoked {
|
||||
rt.Revoke()
|
||||
}
|
||||
|
||||
got := rt.IsValid()
|
||||
if got != tt.want {
|
||||
t.Errorf("IsValid() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshToken_Revoke(t *testing.T) {
|
||||
rt := NewRefreshToken("user-123", "token", time.Now().Add(24*time.Hour))
|
||||
|
||||
if rt.RevokedAt != nil {
|
||||
t.Error("RevokedAt should be nil before Revoke()")
|
||||
}
|
||||
|
||||
if !rt.IsValid() {
|
||||
t.Error("Token should be valid before Revoke()")
|
||||
}
|
||||
|
||||
rt.Revoke()
|
||||
|
||||
if rt.RevokedAt == nil {
|
||||
t.Fatal("RevokedAt should not be nil after Revoke()")
|
||||
}
|
||||
|
||||
if rt.IsValid() {
|
||||
t.Error("Token should not be valid after Revoke()")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
diff := now.Sub(*rt.RevokedAt)
|
||||
if diff > time.Second || diff < 0 {
|
||||
t.Errorf("RevokedAt difference too large: %v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshToken_RevokeMultipleTimes(t *testing.T) {
|
||||
rt := NewRefreshToken("user-123", "token", time.Now().Add(24*time.Hour))
|
||||
|
||||
rt.Revoke()
|
||||
firstRevokedAt := rt.RevokedAt
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
rt.Revoke()
|
||||
|
||||
if rt.RevokedAt == firstRevokedAt {
|
||||
t.Error("Revoke() should update RevokedAt on subsequent calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshToken_ExpirationCheck(t *testing.T) {
|
||||
expiresIn := 100 * time.Millisecond
|
||||
rt := NewRefreshToken("user-123", "token", time.Now().Add(expiresIn))
|
||||
|
||||
if !rt.IsValid() {
|
||||
t.Error("Token should be valid initially")
|
||||
}
|
||||
|
||||
time.Sleep(expiresIn + 10*time.Millisecond)
|
||||
|
||||
if rt.IsValid() {
|
||||
t.Error("Token should be invalid after expiration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshToken_MultipleTokens(t *testing.T) {
|
||||
token1 := NewRefreshToken("user-1", "token-1", time.Now().Add(24*time.Hour))
|
||||
token2 := NewRefreshToken("user-2", "token-2", time.Now().Add(24*time.Hour))
|
||||
|
||||
if token1.UserID == token2.UserID {
|
||||
t.Error("UserIDs should be different")
|
||||
}
|
||||
|
||||
if token1.Token == token2.Token {
|
||||
t.Error("Tokens should be different")
|
||||
}
|
||||
|
||||
if token1.CreatedAt.Equal(token2.CreatedAt) {
|
||||
t.Log("Warning: CreatedAt timestamps are identical (possible race condition in test)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func TestNewJWTService(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
secret string
|
||||
expiryHours int
|
||||
}{
|
||||
{
|
||||
name: "standard configuration",
|
||||
secret: "my-secret-key",
|
||||
expiryHours: 24,
|
||||
},
|
||||
{
|
||||
name: "short expiry",
|
||||
secret: "test-secret",
|
||||
expiryHours: 1,
|
||||
},
|
||||
{
|
||||
name: "long expiry",
|
||||
secret: "test-secret",
|
||||
expiryHours: 168,
|
||||
},
|
||||
{
|
||||
name: "empty secret",
|
||||
secret: "",
|
||||
expiryHours: 24,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
service := NewJWTService(tt.secret, tt.expiryHours)
|
||||
if service == nil {
|
||||
t.Fatal("NewJWTService() returned nil")
|
||||
}
|
||||
|
||||
if string(service.secret) != tt.secret {
|
||||
t.Errorf("secret = %v, want %v", string(service.secret), tt.secret)
|
||||
}
|
||||
|
||||
expectedExpiry := time.Duration(tt.expiryHours) * time.Hour
|
||||
if service.expiry != expectedExpiry {
|
||||
t.Errorf("expiry = %v, want %v", service.expiry, expectedExpiry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTService_GenerateToken(t *testing.T) {
|
||||
service := NewJWTService("test-secret-key", 24)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
userID string
|
||||
email string
|
||||
}{
|
||||
{
|
||||
name: "standard user",
|
||||
userID: "user-123",
|
||||
email: "user@example.com",
|
||||
},
|
||||
{
|
||||
name: "empty user ID",
|
||||
userID: "",
|
||||
email: "user@example.com",
|
||||
},
|
||||
{
|
||||
name: "empty email",
|
||||
userID: "user-123",
|
||||
email: "",
|
||||
},
|
||||
{
|
||||
name: "both empty",
|
||||
userID: "",
|
||||
email: "",
|
||||
},
|
||||
{
|
||||
name: "special characters in email",
|
||||
userID: "user-456",
|
||||
email: "user+test@example.com",
|
||||
},
|
||||
{
|
||||
name: "uuid as user ID",
|
||||
userID: "550e8400-e29b-41d4-a716-446655440000",
|
||||
email: "uuid@example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
token, err := service.GenerateToken(tt.userID, tt.email)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() error = %v", err)
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
t.Fatal("GenerateToken() returned empty token")
|
||||
}
|
||||
|
||||
claims, err := service.ValidateToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken() error = %v", err)
|
||||
}
|
||||
|
||||
if claims.UserID != tt.userID {
|
||||
t.Errorf("UserID = %v, want %v", claims.UserID, tt.userID)
|
||||
}
|
||||
|
||||
if claims.Email != tt.email {
|
||||
t.Errorf("Email = %v, want %v", claims.Email, tt.email)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTService_ValidateToken(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
service := NewJWTService(secret, 24)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupToken func() string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid token",
|
||||
setupToken: func() string {
|
||||
token, _ := service.GenerateToken("user-123", "user@example.com")
|
||||
return token
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid token format",
|
||||
setupToken: func() string {
|
||||
return "not.a.valid.token"
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty token",
|
||||
setupToken: func() string {
|
||||
return ""
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "token with wrong secret",
|
||||
setupToken: func() string {
|
||||
wrongService := NewJWTService("wrong-secret", 24)
|
||||
token, _ := wrongService.GenerateToken("user-123", "user@example.com")
|
||||
return token
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "expired token",
|
||||
setupToken: func() string {
|
||||
expiredService := NewJWTService(secret, -1)
|
||||
token, _ := expiredService.GenerateToken("user-123", "user@example.com")
|
||||
return token
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "malformed token",
|
||||
setupToken: func() string {
|
||||
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.malformed"
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "token with invalid signature",
|
||||
setupToken: func() string {
|
||||
token, _ := service.GenerateToken("user-123", "user@example.com")
|
||||
return token[:len(token)-5] + "xxxxx"
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
token := tt.setupToken()
|
||||
claims, err := service.ValidateToken(token)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatal("ValidateToken() expected error but got nil")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken() unexpected error = %v", err)
|
||||
}
|
||||
if claims == nil {
|
||||
t.Fatal("ValidateToken() returned nil claims")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTService_ValidateTokenClaims(t *testing.T) {
|
||||
service := NewJWTService("test-secret-key", 24)
|
||||
|
||||
userID := "user-123"
|
||||
email := "user@example.com"
|
||||
|
||||
token, err := service.GenerateToken(userID, email)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() error = %v", err)
|
||||
}
|
||||
|
||||
claims, err := service.ValidateToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken() error = %v", err)
|
||||
}
|
||||
|
||||
if claims.UserID != userID {
|
||||
t.Errorf("UserID = %v, want %v", claims.UserID, userID)
|
||||
}
|
||||
|
||||
if claims.Email != email {
|
||||
t.Errorf("Email = %v, want %v", claims.Email, email)
|
||||
}
|
||||
|
||||
if claims.ExpiresAt == nil {
|
||||
t.Fatal("ExpiresAt is nil")
|
||||
}
|
||||
|
||||
if claims.IssuedAt == nil {
|
||||
t.Fatal("IssuedAt is nil")
|
||||
}
|
||||
|
||||
if claims.ExpiresAt.Before(claims.IssuedAt.Time) {
|
||||
t.Error("ExpiresAt is before IssuedAt")
|
||||
}
|
||||
|
||||
expectedExpiry := claims.IssuedAt.Add(24 * time.Hour)
|
||||
if !claims.ExpiresAt.Time.Equal(expectedExpiry) {
|
||||
diff := claims.ExpiresAt.Time.Sub(expectedExpiry)
|
||||
if diff > time.Second || diff < -time.Second {
|
||||
t.Errorf("ExpiresAt = %v, want approximately %v (diff: %v)", claims.ExpiresAt.Time, expectedExpiry, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTService_TokenExpiry(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expiryHours int
|
||||
}{
|
||||
{
|
||||
name: "1 hour expiry",
|
||||
expiryHours: 1,
|
||||
},
|
||||
{
|
||||
name: "24 hours expiry",
|
||||
expiryHours: 24,
|
||||
},
|
||||
{
|
||||
name: "168 hours (1 week) expiry",
|
||||
expiryHours: 168,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
service := NewJWTService("test-secret", tt.expiryHours)
|
||||
token, err := service.GenerateToken("user-123", "user@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() error = %v", err)
|
||||
}
|
||||
|
||||
claims, err := service.ValidateToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken() error = %v", err)
|
||||
}
|
||||
|
||||
expectedExpiry := time.Now().Add(time.Duration(tt.expiryHours) * time.Hour)
|
||||
diff := claims.ExpiresAt.Time.Sub(expectedExpiry)
|
||||
|
||||
if diff > time.Second || diff < -time.Second {
|
||||
t.Errorf("ExpiresAt difference too large: %v", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTService_ValidateTokenWithWrongSigningMethod(t *testing.T) {
|
||||
service := NewJWTService("test-secret", 24)
|
||||
|
||||
claims := &Claims{
|
||||
UserID: "user-123",
|
||||
Email: "user@example.com",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
|
||||
tokenString, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = service.ValidateToken(tokenString)
|
||||
if err == nil {
|
||||
t.Fatal("ValidateToken() expected error for none signing method but got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTService_MultipleTokens(t *testing.T) {
|
||||
service := NewJWTService("test-secret", 24)
|
||||
|
||||
token1, err := service.GenerateToken("user-1", "user1@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken(1) error = %v", err)
|
||||
}
|
||||
|
||||
token2, err := service.GenerateToken("user-2", "user2@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken(2) error = %v", err)
|
||||
}
|
||||
|
||||
if token1 == token2 {
|
||||
t.Error("Generated identical tokens for different users")
|
||||
}
|
||||
|
||||
claims1, err := service.ValidateToken(token1)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken(1) error = %v", err)
|
||||
}
|
||||
|
||||
claims2, err := service.ValidateToken(token2)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken(2) error = %v", err)
|
||||
}
|
||||
|
||||
if claims1.UserID == claims2.UserID {
|
||||
t.Error("Claims have same UserID")
|
||||
}
|
||||
|
||||
if claims1.Email == claims2.Email {
|
||||
t.Error("Claims have same Email")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoad_Success(t *testing.T) {
|
||||
os.Setenv("DB_PATH", "/test/db.sqlite")
|
||||
os.Setenv("APP_URL", "http://test.com")
|
||||
os.Setenv("JWT_SECRET", "test-secret")
|
||||
os.Setenv("JWT_EXPIRY", "1h")
|
||||
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
|
||||
os.Setenv("DEFAULT_TIMEZONE", "UTC")
|
||||
defer clearEnv()
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.DBPath != "/test/db.sqlite" {
|
||||
t.Errorf("DBPath = %v, want %v", cfg.DBPath, "/test/db.sqlite")
|
||||
}
|
||||
if cfg.AppURL != "http://test.com" {
|
||||
t.Errorf("AppURL = %v, want %v", cfg.AppURL, "http://test.com")
|
||||
}
|
||||
if cfg.JWTSecret != "test-secret" {
|
||||
t.Errorf("JWTSecret = %v, want %v", cfg.JWTSecret, "test-secret")
|
||||
}
|
||||
if cfg.JWTExpiry != "1h" {
|
||||
t.Errorf("JWTExpiry = %v, want %v", cfg.JWTExpiry, "1h")
|
||||
}
|
||||
if cfg.RefreshTokenExpiry != "7d" {
|
||||
t.Errorf("RefreshTokenExpiry = %v, want %v", cfg.RefreshTokenExpiry, "7d")
|
||||
}
|
||||
if cfg.DefaultTimezone != "UTC" {
|
||||
t.Errorf("DefaultTimezone = %v, want %v", cfg.DefaultTimezone, "UTC")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_WithDefaults(t *testing.T) {
|
||||
os.Setenv("DB_PATH", "/test/db.sqlite")
|
||||
os.Setenv("APP_URL", "http://test.com")
|
||||
os.Setenv("JWT_SECRET", "test-secret")
|
||||
os.Setenv("JWT_EXPIRY", "1h")
|
||||
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
|
||||
os.Setenv("DEFAULT_TIMEZONE", "UTC")
|
||||
defer clearEnv()
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Port != "8080" {
|
||||
t.Errorf("Port = %v, want default %v", cfg.Port, "8080")
|
||||
}
|
||||
|
||||
if cfg.SMTPPort != "587" {
|
||||
t.Errorf("SMTPPort = %v, want default %v", cfg.SMTPPort, "587")
|
||||
}
|
||||
|
||||
if cfg.SupportEmail != "contact@apocapoc.app" {
|
||||
t.Errorf("SupportEmail = %v, want default %v", cfg.SupportEmail, "contact@apocapoc.app")
|
||||
}
|
||||
|
||||
if cfg.SendWelcomeEmail != "false" {
|
||||
t.Errorf("SendWelcomeEmail = %v, want default %v", cfg.SendWelcomeEmail, "false")
|
||||
}
|
||||
|
||||
if cfg.RegistrationMode != "open" {
|
||||
t.Errorf("RegistrationMode = %v, want default %v", cfg.RegistrationMode, "open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_WithCustomDefaults(t *testing.T) {
|
||||
os.Setenv("DB_PATH", "/test/db.sqlite")
|
||||
os.Setenv("APP_URL", "http://test.com")
|
||||
os.Setenv("JWT_SECRET", "test-secret")
|
||||
os.Setenv("JWT_EXPIRY", "1h")
|
||||
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
|
||||
os.Setenv("DEFAULT_TIMEZONE", "UTC")
|
||||
os.Setenv("PORT", "3000")
|
||||
os.Setenv("SMTP_PORT", "465")
|
||||
os.Setenv("SUPPORT_EMAIL", "support@test.com")
|
||||
os.Setenv("SEND_WELCOME_EMAIL", "true")
|
||||
os.Setenv("REGISTRATION_MODE", "closed")
|
||||
defer clearEnv()
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Port != "3000" {
|
||||
t.Errorf("Port = %v, want %v", cfg.Port, "3000")
|
||||
}
|
||||
|
||||
if cfg.SMTPPort != "465" {
|
||||
t.Errorf("SMTPPort = %v, want %v", cfg.SMTPPort, "465")
|
||||
}
|
||||
|
||||
if cfg.SupportEmail != "support@test.com" {
|
||||
t.Errorf("SupportEmail = %v, want %v", cfg.SupportEmail, "support@test.com")
|
||||
}
|
||||
|
||||
if cfg.SendWelcomeEmail != "true" {
|
||||
t.Errorf("SendWelcomeEmail = %v, want %v", cfg.SendWelcomeEmail, "true")
|
||||
}
|
||||
|
||||
if cfg.RegistrationMode != "closed" {
|
||||
t.Errorf("RegistrationMode = %v, want %v", cfg.RegistrationMode, "closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingDBPath(t *testing.T) {
|
||||
clearEnv()
|
||||
os.Setenv("APP_URL", "http://test.com")
|
||||
os.Setenv("JWT_SECRET", "test-secret")
|
||||
os.Setenv("JWT_EXPIRY", "1h")
|
||||
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
|
||||
os.Setenv("DEFAULT_TIMEZONE", "UTC")
|
||||
defer clearEnv()
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("Load() expected error for missing DB_PATH but got nil")
|
||||
}
|
||||
|
||||
expectedMsg := "DB_PATH is required"
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingJWTSecret(t *testing.T) {
|
||||
clearEnv()
|
||||
os.Setenv("DB_PATH", "/test/db.sqlite")
|
||||
os.Setenv("APP_URL", "http://test.com")
|
||||
os.Setenv("JWT_EXPIRY", "1h")
|
||||
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
|
||||
os.Setenv("DEFAULT_TIMEZONE", "UTC")
|
||||
defer clearEnv()
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("Load() expected error for missing JWT_SECRET but got nil")
|
||||
}
|
||||
|
||||
expectedMsg := "JWT_SECRET is required"
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingJWTExpiry(t *testing.T) {
|
||||
clearEnv()
|
||||
os.Setenv("DB_PATH", "/test/db.sqlite")
|
||||
os.Setenv("APP_URL", "http://test.com")
|
||||
os.Setenv("JWT_SECRET", "test-secret")
|
||||
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
|
||||
os.Setenv("DEFAULT_TIMEZONE", "UTC")
|
||||
defer clearEnv()
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("Load() expected error for missing JWT_EXPIRY but got nil")
|
||||
}
|
||||
|
||||
expectedMsg := "JWT_EXPIRY is required"
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingRefreshTokenExpiry(t *testing.T) {
|
||||
clearEnv()
|
||||
os.Setenv("DB_PATH", "/test/db.sqlite")
|
||||
os.Setenv("APP_URL", "http://test.com")
|
||||
os.Setenv("JWT_SECRET", "test-secret")
|
||||
os.Setenv("JWT_EXPIRY", "1h")
|
||||
os.Setenv("DEFAULT_TIMEZONE", "UTC")
|
||||
defer clearEnv()
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("Load() expected error for missing REFRESH_TOKEN_EXPIRY but got nil")
|
||||
}
|
||||
|
||||
expectedMsg := "REFRESH_TOKEN_EXPIRY is required"
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingDefaultTimezone(t *testing.T) {
|
||||
clearEnv()
|
||||
os.Setenv("DB_PATH", "/test/db.sqlite")
|
||||
os.Setenv("APP_URL", "http://test.com")
|
||||
os.Setenv("JWT_SECRET", "test-secret")
|
||||
os.Setenv("JWT_EXPIRY", "1h")
|
||||
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
|
||||
defer clearEnv()
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("Load() expected error for missing DEFAULT_TIMEZONE but got nil")
|
||||
}
|
||||
|
||||
expectedMsg := "DEFAULT_TIMEZONE is required"
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_WithSMTPConfig(t *testing.T) {
|
||||
os.Setenv("DB_PATH", "/test/db.sqlite")
|
||||
os.Setenv("APP_URL", "http://test.com")
|
||||
os.Setenv("JWT_SECRET", "test-secret")
|
||||
os.Setenv("JWT_EXPIRY", "1h")
|
||||
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
|
||||
os.Setenv("DEFAULT_TIMEZONE", "UTC")
|
||||
os.Setenv("SMTP_HOST", "smtp.test.com")
|
||||
os.Setenv("SMTP_PORT", "587")
|
||||
os.Setenv("SMTP_USER", "user@test.com")
|
||||
os.Setenv("SMTP_PASSWORD", "test-password")
|
||||
os.Setenv("SMTP_FROM", "noreply@test.com")
|
||||
defer clearEnv()
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.SMTPHost != "smtp.test.com" {
|
||||
t.Errorf("SMTPHost = %v, want %v", cfg.SMTPHost, "smtp.test.com")
|
||||
}
|
||||
if cfg.SMTPPort != "587" {
|
||||
t.Errorf("SMTPPort = %v, want %v", cfg.SMTPPort, "587")
|
||||
}
|
||||
if cfg.SMTPUser != "user@test.com" {
|
||||
t.Errorf("SMTPUser = %v, want %v", cfg.SMTPUser, "user@test.com")
|
||||
}
|
||||
if cfg.SMTPPassword != "test-password" {
|
||||
t.Errorf("SMTPPassword = %v, want %v", cfg.SMTPPassword, "test-password")
|
||||
}
|
||||
if cfg.SMTPFrom != "noreply@test.com" {
|
||||
t.Errorf("SMTPFrom = %v, want %v", cfg.SMTPFrom, "noreply@test.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvOrDefault(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
defaultValue string
|
||||
envValue string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "env value exists",
|
||||
key: "TEST_KEY",
|
||||
defaultValue: "default",
|
||||
envValue: "custom",
|
||||
want: "custom",
|
||||
},
|
||||
{
|
||||
name: "env value empty uses default",
|
||||
key: "TEST_KEY_EMPTY",
|
||||
defaultValue: "default",
|
||||
envValue: "",
|
||||
want: "default",
|
||||
},
|
||||
{
|
||||
name: "env value not set uses default",
|
||||
key: "TEST_KEY_NOT_SET",
|
||||
defaultValue: "default",
|
||||
envValue: "",
|
||||
want: "default",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.envValue != "" {
|
||||
os.Setenv(tt.key, tt.envValue)
|
||||
defer os.Unsetenv(tt.key)
|
||||
}
|
||||
|
||||
got := getEnvOrDefault(tt.key, tt.defaultValue)
|
||||
if got != tt.want {
|
||||
t.Errorf("getEnvOrDefault() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func clearEnv() {
|
||||
os.Unsetenv("DB_PATH")
|
||||
os.Unsetenv("PORT")
|
||||
os.Unsetenv("APP_URL")
|
||||
os.Unsetenv("JWT_SECRET")
|
||||
os.Unsetenv("JWT_EXPIRY")
|
||||
os.Unsetenv("REFRESH_TOKEN_EXPIRY")
|
||||
os.Unsetenv("DEFAULT_TIMEZONE")
|
||||
os.Unsetenv("SMTP_HOST")
|
||||
os.Unsetenv("SMTP_PORT")
|
||||
os.Unsetenv("SMTP_USER")
|
||||
os.Unsetenv("SMTP_PASSWORD")
|
||||
os.Unsetenv("SMTP_FROM")
|
||||
os.Unsetenv("SUPPORT_EMAIL")
|
||||
os.Unsetenv("SEND_WELCOME_EMAIL")
|
||||
os.Unsetenv("REGISTRATION_MODE")
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestNewBcryptHasher(t *testing.T) {
|
||||
hasher := NewBcryptHasher()
|
||||
if hasher == nil {
|
||||
t.Fatal("NewBcryptHasher() returned nil")
|
||||
}
|
||||
|
||||
_, ok := hasher.(*BcryptHasher)
|
||||
if !ok {
|
||||
t.Fatal("NewBcryptHasher() did not return *BcryptHasher")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBcryptHasher_Hash(t *testing.T) {
|
||||
hasher := NewBcryptHasher()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
}{
|
||||
{
|
||||
name: "simple password",
|
||||
password: "password123",
|
||||
},
|
||||
{
|
||||
name: "complex password",
|
||||
password: "P@ssw0rd!123$%^&*()",
|
||||
},
|
||||
{
|
||||
name: "empty password",
|
||||
password: "",
|
||||
},
|
||||
{
|
||||
name: "unicode password",
|
||||
password: "pässwörd123",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hashed, err := hasher.Hash(tt.password)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash() error = %v", err)
|
||||
}
|
||||
|
||||
if hashed == "" {
|
||||
t.Fatal("Hash() returned empty string")
|
||||
}
|
||||
|
||||
if hashed == tt.password {
|
||||
t.Fatal("Hash() returned the same as input password")
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(hashed), []byte(tt.password))
|
||||
if err != nil {
|
||||
t.Fatalf("Generated hash does not match original password: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBcryptHasher_Compare(t *testing.T) {
|
||||
hasher := NewBcryptHasher()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
compareWith string
|
||||
expectError bool
|
||||
errorAssertion func(error) bool
|
||||
}{
|
||||
{
|
||||
name: "matching passwords",
|
||||
password: "password123",
|
||||
compareWith: "password123",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "non-matching passwords",
|
||||
password: "password123",
|
||||
compareWith: "wrongpassword",
|
||||
expectError: true,
|
||||
errorAssertion: func(err error) bool {
|
||||
return err == bcrypt.ErrMismatchedHashAndPassword
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty password comparison",
|
||||
password: "",
|
||||
compareWith: "",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "unicode password match",
|
||||
password: "pässwörd123",
|
||||
compareWith: "pässwörd123",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "unicode password mismatch",
|
||||
password: "pässwörd123",
|
||||
compareWith: "password123",
|
||||
expectError: true,
|
||||
errorAssertion: func(err error) bool {
|
||||
return err == bcrypt.ErrMismatchedHashAndPassword
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "case sensitive",
|
||||
password: "Password123",
|
||||
compareWith: "password123",
|
||||
expectError: true,
|
||||
errorAssertion: func(err error) bool {
|
||||
return err == bcrypt.ErrMismatchedHashAndPassword
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hashed, err := hasher.Hash(tt.password)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash() error = %v", err)
|
||||
}
|
||||
|
||||
err = hasher.Compare(hashed, tt.compareWith)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatal("Compare() expected error but got nil")
|
||||
}
|
||||
if tt.errorAssertion != nil && !tt.errorAssertion(err) {
|
||||
t.Fatalf("Compare() error = %v, but assertion failed", err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("Compare() unexpected error = %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBcryptHasher_CompareWithInvalidHash(t *testing.T) {
|
||||
hasher := NewBcryptHasher()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
invalidHash string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "invalid hash format",
|
||||
invalidHash: "not-a-valid-hash",
|
||||
password: "password123",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty hash",
|
||||
invalidHash: "",
|
||||
password: "password123",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "corrupted hash",
|
||||
invalidHash: "$2a$10$invalidhashdata",
|
||||
password: "password123",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := hasher.Compare(tt.invalidHash, tt.password)
|
||||
if !tt.expectError && err != nil {
|
||||
t.Fatalf("Compare() unexpected error = %v", err)
|
||||
}
|
||||
if tt.expectError && err == nil {
|
||||
t.Fatal("Compare() expected error but got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBcryptHasher_HashGeneratesDifferentHashes(t *testing.T) {
|
||||
hasher := NewBcryptHasher()
|
||||
password := "samePassword123"
|
||||
|
||||
hash1, err := hasher.Hash(password)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash() error = %v", err)
|
||||
}
|
||||
|
||||
hash2, err := hasher.Hash(password)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash() error = %v", err)
|
||||
}
|
||||
|
||||
if hash1 == hash2 {
|
||||
t.Fatal("Hash() generated identical hashes for same password (should use salt)")
|
||||
}
|
||||
|
||||
if err := hasher.Compare(hash1, password); err != nil {
|
||||
t.Fatalf("First hash doesn't match password: %v", err)
|
||||
}
|
||||
|
||||
if err := hasher.Compare(hash2, password); err != nil {
|
||||
t.Fatalf("Second hash doesn't match password: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -42,12 +42,12 @@ func setupTestServer(t *testing.T) *TestServer {
|
||||
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db)
|
||||
passwordResetTokenRepo := sqlite.NewPasswordResetTokenRepository(db)
|
||||
|
||||
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, nil, "", "open")
|
||||
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, nil, "", "open", false)
|
||||
loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
|
||||
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
|
||||
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
|
||||
revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo)
|
||||
verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo)
|
||||
verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo, nil, false)
|
||||
resendVerificationEmailHandler := commands.NewResendVerificationEmailHandler(userRepo, nil, "")
|
||||
requestPasswordResetHandler := commands.NewRequestPasswordResetHandler(userRepo, passwordResetTokenRepo, nil, "")
|
||||
resetPasswordHandler := commands.NewResetPasswordHandler(userRepo, passwordResetTokenRepo, passwordHasher)
|
||||
|
||||
@@ -3,11 +3,11 @@ package errors
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
ErrAlreadyExists = errors.New("resource already exists")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrEmailNotVerified = errors.New("email not verified")
|
||||
ErrRegistrationClosed = errors.New("registration is closed")
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
ErrAlreadyExists = errors.New("resource already exists")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrEmailNotVerified = errors.New("email not verified")
|
||||
ErrRegistrationClosed = errors.New("registration is closed")
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user