feat: implement Iteration 0 foundation (backend + Flutter client)

Backend (Go):
- Project structure with chi router, pgxpool, goose migrations
- JWT auth (access/refresh tokens) with Firebase token verification
- NoopTokenVerifier for local dev without Firebase credentials
- PostgreSQL user repository with atomic profile updates (transactions)
- Mifflin-St Jeor calorie calculation based on profile data
- REST API: POST /auth/login, /auth/refresh, /auth/logout, GET/PUT /profile, GET /health
- Middleware: auth, CORS (localhost wildcard), logging, recovery, request_id
- Unit tests (51 passing) and integration tests (testcontainers)
- Docker Compose setup with postgres healthcheck and graceful shutdown

Flutter client:
- Riverpod state management with GoRouter navigation
- Firebase Auth (email/password + Google sign-in with web popup support)
- Platform-aware API URLs (web/Android/iOS)
- Dio HTTP client with JWT auth interceptor and concurrent refresh handling
- Secure token storage
- Screens: Login, Register, Home (tabs: Menu, Recipes, Products, Profile)
- Unit tests (17 passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-02-20 13:14:58 +02:00
commit 24219b611e
140 changed files with 13062 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
firebase "firebase.google.com/go/v4"
firebaseAuth "firebase.google.com/go/v4/auth"
"google.golang.org/api/option"
)
// TokenVerifier abstracts Firebase token verification for testability.
type TokenVerifier interface {
VerifyToken(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error)
}
type FirebaseAuth struct {
client *firebaseAuth.Client
}
// noopTokenVerifier is used in local development when Firebase credentials are not available.
// It rejects all tokens with a clear error message.
type noopTokenVerifier struct{}
func (n *noopTokenVerifier) VerifyToken(_ context.Context, _ string) (uid, email, name, avatarURL string, err error) {
return "", "", "", "", fmt.Errorf("Firebase auth is not configured (running in noop mode)")
}
// NewFirebaseAuthOrNoop tries to initialize Firebase auth from the credentials file.
// If the file is missing, empty, or not a valid service account, it logs a warning
// and returns a noop verifier that rejects all tokens.
func NewFirebaseAuthOrNoop(credentialsFile string) (TokenVerifier, error) {
data, err := os.ReadFile(credentialsFile)
if err != nil || len(data) == 0 {
slog.Warn("firebase credentials not found, running without Firebase auth", "file", credentialsFile)
return &noopTokenVerifier{}, nil
}
var creds map[string]interface{}
if err := json.Unmarshal(data, &creds); err != nil || creds["type"] == nil {
slog.Warn("firebase credentials invalid, running without Firebase auth", "file", credentialsFile)
return &noopTokenVerifier{}, nil
}
fa, err := NewFirebaseAuth(credentialsFile)
if err != nil {
return nil, err
}
return fa, nil
}
func NewFirebaseAuth(credentialsFile string) (*FirebaseAuth, error) {
opt := option.WithCredentialsFile(credentialsFile)
app, err := firebase.NewApp(context.Background(), nil, opt)
if err != nil {
return nil, err
}
client, err := app.Auth(context.Background())
if err != nil {
return nil, err
}
return &FirebaseAuth{client: client}, nil
}
func (f *FirebaseAuth) VerifyToken(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error) {
token, err := f.client.VerifyIDToken(ctx, idToken)
if err != nil {
return "", "", "", "", err
}
uid = token.UID
if v, ok := token.Claims["email"].(string); ok {
email = v
}
if v, ok := token.Claims["name"].(string); ok {
name = v
}
if v, ok := token.Claims["picture"].(string); ok {
avatarURL = v
}
return uid, email, name, avatarURL, nil
}

View File

@@ -0,0 +1,106 @@
package auth
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/food-ai/backend/internal/middleware"
)
const maxRequestBodySize = 1 << 20 // 1 MB
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
type loginRequest struct {
FirebaseToken string `json:"firebase_token"`
}
type refreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
return
}
if req.FirebaseToken == "" {
writeErrorJSON(w, http.StatusBadRequest, "firebase_token is required")
return
}
resp, err := h.service.Login(r.Context(), req.FirebaseToken)
if err != nil {
writeErrorJSON(w, http.StatusUnauthorized, "authentication failed")
return
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
var req refreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
return
}
if req.RefreshToken == "" {
writeErrorJSON(w, http.StatusBadRequest, "refresh_token is required")
return
}
resp, err := h.service.Refresh(r.Context(), req.RefreshToken)
if err != nil {
writeErrorJSON(w, http.StatusUnauthorized, "invalid refresh token")
return
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
if err := h.service.Logout(r.Context(), userID); err != nil {
writeErrorJSON(w, http.StatusInternalServerError, "logout failed")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
type errorResponse struct {
Error string `json:"error"`
}
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
slog.Error("failed to write error response", "err", err)
}
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("failed to write JSON response", "err", err)
}
}

View File

@@ -0,0 +1,272 @@
//go:build integration
package auth
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/food-ai/backend/internal/auth/mocks"
"github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/testutil"
"github.com/food-ai/backend/internal/user"
"github.com/go-chi/chi/v5"
)
// testValidator adapts JWTManager to middleware.AccessTokenValidator for tests.
type testValidator struct {
jm *JWTManager
}
func (v *testValidator) ValidateAccessToken(tokenStr string) (*middleware.TokenClaims, error) {
claims, err := v.jm.ValidateAccessToken(tokenStr)
if err != nil {
return nil, err
}
return &middleware.TokenClaims{UserID: claims.UserID, Plan: claims.Plan}, nil
}
func setupIntegrationTest(t *testing.T) (*chi.Mux, *JWTManager) {
t.Helper()
pool := testutil.SetupTestDB(t)
verifier := &mocks.MockTokenVerifier{
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
return "fb-" + idToken, idToken + "@test.com", "Test User", "", nil
},
}
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
repo := user.NewRepository(pool)
svc := NewService(verifier, repo, jm)
handler := NewHandler(svc)
r := chi.NewRouter()
r.Post("/auth/login", handler.Login)
r.Post("/auth/refresh", handler.Refresh)
r.Group(func(r chi.Router) {
r.Use(middleware.Auth(&testValidator{jm: jm}))
r.Post("/auth/logout", handler.Logout)
})
return r, jm
}
func TestIntegration_Login(t *testing.T) {
router, _ := setupIntegrationTest(t)
body := `{"firebase_token":"user1"}`
req := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp LoginResponse
json.NewDecoder(rr.Body).Decode(&resp)
if resp.AccessToken == "" {
t.Error("expected non-empty access token")
}
if resp.RefreshToken == "" {
t.Error("expected non-empty refresh token")
}
if resp.User == nil {
t.Fatal("expected user in response")
}
}
func TestIntegration_Login_EmptyToken(t *testing.T) {
router, _ := setupIntegrationTest(t)
body := `{"firebase_token":""}`
req := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
}
func TestIntegration_Login_InvalidBody(t *testing.T) {
router, _ := setupIntegrationTest(t)
req := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString("invalid"))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
}
func TestIntegration_Refresh(t *testing.T) {
router, _ := setupIntegrationTest(t)
// First login
loginBody := `{"firebase_token":"user2"}`
loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody))
loginReq.Header.Set("Content-Type", "application/json")
loginRR := httptest.NewRecorder()
router.ServeHTTP(loginRR, loginReq)
var loginResp LoginResponse
json.NewDecoder(loginRR.Body).Decode(&loginResp)
// Then refresh
refreshBody, _ := json.Marshal(refreshRequest{RefreshToken: loginResp.RefreshToken})
refreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody))
refreshReq.Header.Set("Content-Type", "application/json")
refreshRR := httptest.NewRecorder()
router.ServeHTTP(refreshRR, refreshReq)
if refreshRR.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", refreshRR.Code, refreshRR.Body.String())
}
var resp RefreshResponse
json.NewDecoder(refreshRR.Body).Decode(&resp)
if resp.AccessToken == "" {
t.Error("expected non-empty access token")
}
if resp.RefreshToken == loginResp.RefreshToken {
t.Error("expected rotated refresh token")
}
}
func TestIntegration_Refresh_InvalidToken(t *testing.T) {
router, _ := setupIntegrationTest(t)
body := `{"refresh_token":"nonexistent"}`
req := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rr.Code)
}
}
func TestIntegration_Refresh_EmptyToken(t *testing.T) {
router, _ := setupIntegrationTest(t)
body := `{"refresh_token":""}`
req := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
}
func TestIntegration_Logout(t *testing.T) {
router, _ := setupIntegrationTest(t)
// Login first
loginBody := `{"firebase_token":"user3"}`
loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody))
loginReq.Header.Set("Content-Type", "application/json")
loginRR := httptest.NewRecorder()
router.ServeHTTP(loginRR, loginReq)
var loginResp LoginResponse
json.NewDecoder(loginRR.Body).Decode(&loginResp)
// Logout
logoutReq := httptest.NewRequest("POST", "/auth/logout", nil)
logoutReq.Header.Set("Authorization", "Bearer "+loginResp.AccessToken)
logoutRR := httptest.NewRecorder()
router.ServeHTTP(logoutRR, logoutReq)
if logoutRR.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", logoutRR.Code, logoutRR.Body.String())
}
}
func TestIntegration_Logout_NoAuth(t *testing.T) {
router, _ := setupIntegrationTest(t)
req := httptest.NewRequest("POST", "/auth/logout", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rr.Code)
}
}
func TestIntegration_RefreshAfterLogout(t *testing.T) {
router, _ := setupIntegrationTest(t)
// Login
loginBody := `{"firebase_token":"user4"}`
loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody))
loginReq.Header.Set("Content-Type", "application/json")
loginRR := httptest.NewRecorder()
router.ServeHTTP(loginRR, loginReq)
var loginResp LoginResponse
json.NewDecoder(loginRR.Body).Decode(&loginResp)
// Logout
logoutReq := httptest.NewRequest("POST", "/auth/logout", nil)
logoutReq.Header.Set("Authorization", "Bearer "+loginResp.AccessToken)
logoutRR := httptest.NewRecorder()
router.ServeHTTP(logoutRR, logoutReq)
// Try to refresh with old token
refreshBody, _ := json.Marshal(refreshRequest{RefreshToken: loginResp.RefreshToken})
refreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody))
refreshReq.Header.Set("Content-Type", "application/json")
refreshRR := httptest.NewRecorder()
router.ServeHTTP(refreshRR, refreshReq)
if refreshRR.Code != http.StatusUnauthorized {
t.Errorf("expected 401 after logout, got %d", refreshRR.Code)
}
}
func TestIntegration_OldRefreshTokenInvalid(t *testing.T) {
router, _ := setupIntegrationTest(t)
// Login
loginBody := `{"firebase_token":"user5"}`
loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody))
loginReq.Header.Set("Content-Type", "application/json")
loginRR := httptest.NewRecorder()
router.ServeHTTP(loginRR, loginReq)
var loginResp LoginResponse
json.NewDecoder(loginRR.Body).Decode(&loginResp)
oldRefreshToken := loginResp.RefreshToken
// Refresh (rotates token)
refreshBody, _ := json.Marshal(refreshRequest{RefreshToken: oldRefreshToken})
refreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody))
refreshReq.Header.Set("Content-Type", "application/json")
refreshRR := httptest.NewRecorder()
router.ServeHTTP(refreshRR, refreshReq)
// Try old refresh token again
oldRefreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody))
oldRefreshReq.Header.Set("Content-Type", "application/json")
oldRefreshRR := httptest.NewRecorder()
router.ServeHTTP(oldRefreshRR, oldRefreshReq)
if oldRefreshRR.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for old refresh token, got %d", oldRefreshRR.Code)
}
}

View File

@@ -0,0 +1,69 @@
package auth
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
type JWTManager struct {
secret []byte
accessDuration time.Duration
refreshDuration time.Duration
}
type Claims struct {
UserID string `json:"user_id"`
Plan string `json:"plan"`
jwt.RegisteredClaims
}
func NewJWTManager(secret string, accessDuration, refreshDuration time.Duration) *JWTManager {
return &JWTManager{
secret: []byte(secret),
accessDuration: accessDuration,
refreshDuration: refreshDuration,
}
}
func (j *JWTManager) GenerateAccessToken(userID, plan string) (string, error) {
claims := Claims{
UserID: userID,
Plan: plan,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(j.accessDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(j.secret)
}
func (j *JWTManager) GenerateRefreshToken() (string, time.Time) {
token := uuid.NewString()
expiresAt := time.Now().Add(j.refreshDuration)
return token, expiresAt
}
func (j *JWTManager) ValidateAccessToken(tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return j.secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
func (j *JWTManager) AccessDuration() time.Duration {
return j.accessDuration
}

View File

@@ -0,0 +1,86 @@
package auth
import (
"testing"
"time"
)
func TestGenerateAccessToken(t *testing.T) {
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
token, err := jm.GenerateAccessToken("user-123", "free")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token == "" {
t.Fatal("expected non-empty token")
}
}
func TestValidateAccessToken_Valid(t *testing.T) {
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
token, _ := jm.GenerateAccessToken("user-123", "free")
claims, err := jm.ValidateAccessToken(token)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if claims.UserID != "user-123" {
t.Errorf("expected user_id 'user-123', got %q", claims.UserID)
}
if claims.Plan != "free" {
t.Errorf("expected plan 'free', got %q", claims.Plan)
}
}
func TestValidateAccessToken_Expired(t *testing.T) {
jm := NewJWTManager("test-secret", -1*time.Second, 720*time.Hour)
token, _ := jm.GenerateAccessToken("user-123", "free")
_, err := jm.ValidateAccessToken(token)
if err == nil {
t.Fatal("expected error for expired token")
}
}
func TestValidateAccessToken_WrongSecret(t *testing.T) {
jm1 := NewJWTManager("secret-1", 15*time.Minute, 720*time.Hour)
jm2 := NewJWTManager("secret-2", 15*time.Minute, 720*time.Hour)
token, _ := jm1.GenerateAccessToken("user-123", "free")
_, err := jm2.ValidateAccessToken(token)
if err == nil {
t.Fatal("expected error for wrong secret")
}
}
func TestValidateAccessToken_InvalidToken(t *testing.T) {
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
_, err := jm.ValidateAccessToken("invalid-token")
if err == nil {
t.Fatal("expected error for invalid token")
}
}
func TestGenerateRefreshToken(t *testing.T) {
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
token, expiresAt := jm.GenerateRefreshToken()
if token == "" {
t.Fatal("expected non-empty refresh token")
}
if expiresAt.Before(time.Now()) {
t.Fatal("expected future expiration time")
}
}
func TestGenerateRefreshToken_Unique(t *testing.T) {
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
token1, _ := jm.GenerateRefreshToken()
token2, _ := jm.GenerateRefreshToken()
if token1 == token2 {
t.Fatal("expected unique refresh tokens")
}
}

View File

@@ -0,0 +1,11 @@
package mocks
import "context"
type MockTokenVerifier struct {
VerifyTokenFn func(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error)
}
func (m *MockTokenVerifier) VerifyToken(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error) {
return m.VerifyTokenFn(ctx, idToken)
}

View File

@@ -0,0 +1,91 @@
package auth
import (
"context"
"fmt"
"github.com/food-ai/backend/internal/user"
)
type Service struct {
tokenVerifier TokenVerifier
userRepo user.UserRepository
jwtManager *JWTManager
}
func NewService(tokenVerifier TokenVerifier, userRepo user.UserRepository, jwtManager *JWTManager) *Service {
return &Service{
tokenVerifier: tokenVerifier,
userRepo: userRepo,
jwtManager: jwtManager,
}
}
type LoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
User *user.User `json:"user"`
}
type RefreshResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
func (s *Service) Login(ctx context.Context, firebaseToken string) (*LoginResponse, error) {
uid, email, name, avatarURL, err := s.tokenVerifier.VerifyToken(ctx, firebaseToken)
if err != nil {
return nil, fmt.Errorf("verify firebase token: %w", err)
}
u, err := s.userRepo.UpsertByFirebaseUID(ctx, uid, email, name, avatarURL)
if err != nil {
return nil, fmt.Errorf("upsert user: %w", err)
}
accessToken, err := s.jwtManager.GenerateAccessToken(u.ID, u.Plan)
if err != nil {
return nil, fmt.Errorf("generate access token: %w", err)
}
refreshToken, expiresAt := s.jwtManager.GenerateRefreshToken()
if err := s.userRepo.SetRefreshToken(ctx, u.ID, refreshToken, expiresAt); err != nil {
return nil, fmt.Errorf("set refresh token: %w", err)
}
return &LoginResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int(s.jwtManager.AccessDuration().Seconds()),
User: u,
}, nil
}
func (s *Service) Refresh(ctx context.Context, refreshToken string) (*RefreshResponse, error) {
u, err := s.userRepo.FindByRefreshToken(ctx, refreshToken)
if err != nil {
return nil, fmt.Errorf("invalid refresh token: %w", err)
}
accessToken, err := s.jwtManager.GenerateAccessToken(u.ID, u.Plan)
if err != nil {
return nil, fmt.Errorf("generate access token: %w", err)
}
newRefreshToken, expiresAt := s.jwtManager.GenerateRefreshToken()
if err := s.userRepo.SetRefreshToken(ctx, u.ID, newRefreshToken, expiresAt); err != nil {
return nil, fmt.Errorf("set refresh token: %w", err)
}
return &RefreshResponse{
AccessToken: accessToken,
RefreshToken: newRefreshToken,
ExpiresIn: int(s.jwtManager.AccessDuration().Seconds()),
}, nil
}
func (s *Service) Logout(ctx context.Context, userID string) error {
return s.userRepo.ClearRefreshToken(ctx, userID)
}

View File

@@ -0,0 +1,213 @@
package auth
import (
"context"
"fmt"
"testing"
"time"
"github.com/food-ai/backend/internal/auth/mocks"
"github.com/food-ai/backend/internal/user"
umocks "github.com/food-ai/backend/internal/user/mocks"
)
func newTestService(verifier *mocks.MockTokenVerifier, repo *umocks.MockUserRepository) *Service {
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
return NewService(verifier, repo, jm)
}
func TestLogin_Success(t *testing.T) {
verifier := &mocks.MockTokenVerifier{
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
return "fb-uid", "test@example.com", "Test User", "https://avatar.url", nil
},
}
repo := &umocks.MockUserRepository{
UpsertByFirebaseUIDFn: func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) {
return &user.User{ID: "user-1", Email: email, Name: name, Plan: "free"}, nil
},
SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error {
return nil
},
}
svc := newTestService(verifier, repo)
resp, err := svc.Login(context.Background(), "firebase-token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.AccessToken == "" {
t.Error("expected non-empty access token")
}
if resp.RefreshToken == "" {
t.Error("expected non-empty refresh token")
}
if resp.User.ID != "user-1" {
t.Errorf("expected user ID 'user-1', got %q", resp.User.ID)
}
}
func TestLogin_InvalidFirebaseToken(t *testing.T) {
verifier := &mocks.MockTokenVerifier{
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
return "", "", "", "", fmt.Errorf("invalid token")
},
}
repo := &umocks.MockUserRepository{}
svc := newTestService(verifier, repo)
_, err := svc.Login(context.Background(), "bad-token")
if err == nil {
t.Fatal("expected error for invalid firebase token")
}
}
func TestLogin_UpsertError(t *testing.T) {
verifier := &mocks.MockTokenVerifier{
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
return "fb-uid", "test@example.com", "Test", "", nil
},
}
repo := &umocks.MockUserRepository{
UpsertByFirebaseUIDFn: func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) {
return nil, fmt.Errorf("db error")
},
}
svc := newTestService(verifier, repo)
_, err := svc.Login(context.Background(), "token")
if err == nil {
t.Fatal("expected error for upsert failure")
}
}
func TestLogin_SetRefreshTokenError(t *testing.T) {
verifier := &mocks.MockTokenVerifier{
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
return "fb-uid", "test@example.com", "Test", "", nil
},
}
repo := &umocks.MockUserRepository{
UpsertByFirebaseUIDFn: func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) {
return &user.User{ID: "user-1", Plan: "free"}, nil
},
SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error {
return fmt.Errorf("db error")
},
}
svc := newTestService(verifier, repo)
_, err := svc.Login(context.Background(), "token")
if err == nil {
t.Fatal("expected error for set refresh token failure")
}
}
func TestRefresh_Success(t *testing.T) {
repo := &umocks.MockUserRepository{
FindByRefreshTokenFn: func(ctx context.Context, token string) (*user.User, error) {
return &user.User{ID: "user-1", Plan: "free"}, nil
},
SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error {
return nil
},
}
verifier := &mocks.MockTokenVerifier{}
svc := newTestService(verifier, repo)
resp, err := svc.Refresh(context.Background(), "valid-refresh-token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.AccessToken == "" {
t.Error("expected non-empty access token")
}
if resp.RefreshToken == "" {
t.Error("expected non-empty refresh token")
}
}
func TestRefresh_InvalidToken(t *testing.T) {
repo := &umocks.MockUserRepository{
FindByRefreshTokenFn: func(ctx context.Context, token string) (*user.User, error) {
return nil, fmt.Errorf("not found")
},
}
verifier := &mocks.MockTokenVerifier{}
svc := newTestService(verifier, repo)
_, err := svc.Refresh(context.Background(), "bad-token")
if err == nil {
t.Fatal("expected error for invalid refresh token")
}
}
func TestRefresh_SetRefreshTokenError(t *testing.T) {
repo := &umocks.MockUserRepository{
FindByRefreshTokenFn: func(ctx context.Context, token string) (*user.User, error) {
return &user.User{ID: "user-1", Plan: "free"}, nil
},
SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error {
return fmt.Errorf("db error")
},
}
verifier := &mocks.MockTokenVerifier{}
svc := newTestService(verifier, repo)
_, err := svc.Refresh(context.Background(), "valid-token")
if err == nil {
t.Fatal("expected error")
}
}
func TestLogout_Success(t *testing.T) {
repo := &umocks.MockUserRepository{
ClearRefreshTokenFn: func(ctx context.Context, id string) error {
return nil
},
}
verifier := &mocks.MockTokenVerifier{}
svc := newTestService(verifier, repo)
err := svc.Logout(context.Background(), "user-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLogout_Error(t *testing.T) {
repo := &umocks.MockUserRepository{
ClearRefreshTokenFn: func(ctx context.Context, id string) error {
return fmt.Errorf("db error")
},
}
verifier := &mocks.MockTokenVerifier{}
svc := newTestService(verifier, repo)
err := svc.Logout(context.Background(), "user-1")
if err == nil {
t.Fatal("expected error")
}
}
func TestLogin_ExpiresIn(t *testing.T) {
verifier := &mocks.MockTokenVerifier{
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
return "fb-uid", "test@example.com", "Test", "", nil
},
}
repo := &umocks.MockUserRepository{
UpsertByFirebaseUIDFn: func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) {
return &user.User{ID: "user-1", Plan: "free"}, nil
},
SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error {
return nil
},
}
svc := newTestService(verifier, repo)
resp, _ := svc.Login(context.Background(), "token")
if resp.ExpiresIn != 900 {
t.Errorf("expected expires_in 900, got %d", resp.ExpiresIn)
}
}