refactor: move all tests to backend/tests/ as black-box packages

All test files relocated from internal/X/ to tests/X/ and converted
to package X_test, using only the public API of each package.

- tests/auth/: jwt, service, handler integration tests
- tests/middleware/: auth, request_id, recovery tests
- tests/user/: calories, service, repository integration tests
- tests/locale/: locale tests (already package locale_test, just moved)
- tests/ingredient/: repository integration tests
- tests/recipe/: repository integration tests

mockUserRepo in tests/user/service_test.go redefined locally with
fully-qualified user.* types. Unexported auth.refreshRequest replaced
with a local testRefreshRequest struct in the integration test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-15 18:57:19 +02:00
parent 0ce111fa08
commit 33a5297c3a
12 changed files with 346 additions and 325 deletions

View File

@@ -0,0 +1,278 @@
//go:build integration
package auth_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/food-ai/backend/internal/auth"
"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"
)
// testRefreshRequest mirrors the unexported handler request type for test marshalling.
type testRefreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
// testValidator adapts auth.JWTManager to middleware.AccessTokenValidator for tests.
type testValidator struct {
jm *auth.JWTManager
}
func (v *testValidator) ValidateAccessToken(tokenStr string) (*middleware.TokenClaims, error) {
claims, validateError := v.jm.ValidateAccessToken(tokenStr)
if validateError != nil {
return nil, validateError
}
return &middleware.TokenClaims{UserID: claims.UserID, Plan: claims.Plan}, nil
}
func setupIntegrationTest(t *testing.T) (*chi.Mux, *auth.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 := auth.NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
repo := user.NewRepository(pool)
svc := auth.NewService(verifier, repo, jm)
handler := auth.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 auth.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 auth.LoginResponse
json.NewDecoder(loginRR.Body).Decode(&loginResp)
// Then refresh
refreshBody, _ := json.Marshal(testRefreshRequest{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 auth.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 auth.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 auth.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(testRefreshRequest{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 auth.LoginResponse
json.NewDecoder(loginRR.Body).Decode(&loginResp)
oldRefreshToken := loginResp.RefreshToken
// Refresh (rotates token)
refreshBody, _ := json.Marshal(testRefreshRequest{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,88 @@
package auth_test
import (
"testing"
"time"
"github.com/food-ai/backend/internal/auth"
)
func TestGenerateAccessToken(t *testing.T) {
jm := auth.NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
token, tokenError := jm.GenerateAccessToken("user-123", "free")
if tokenError != nil {
t.Fatalf("unexpected error: %v", tokenError)
}
if token == "" {
t.Fatal("expected non-empty token")
}
}
func TestValidateAccessToken_Valid(t *testing.T) {
jm := auth.NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
token, _ := jm.GenerateAccessToken("user-123", "free")
claims, validateError := jm.ValidateAccessToken(token)
if validateError != nil {
t.Fatalf("unexpected error: %v", validateError)
}
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 := auth.NewJWTManager("test-secret", -1*time.Second, 720*time.Hour)
token, _ := jm.GenerateAccessToken("user-123", "free")
_, validateError := jm.ValidateAccessToken(token)
if validateError == nil {
t.Fatal("expected error for expired token")
}
}
func TestValidateAccessToken_WrongSecret(t *testing.T) {
jm1 := auth.NewJWTManager("secret-1", 15*time.Minute, 720*time.Hour)
jm2 := auth.NewJWTManager("secret-2", 15*time.Minute, 720*time.Hour)
token, _ := jm1.GenerateAccessToken("user-123", "free")
_, validateError := jm2.ValidateAccessToken(token)
if validateError == nil {
t.Fatal("expected error for wrong secret")
}
}
func TestValidateAccessToken_InvalidToken(t *testing.T) {
jm := auth.NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
_, validateError := jm.ValidateAccessToken("invalid-token")
if validateError == nil {
t.Fatal("expected error for invalid token")
}
}
func TestGenerateRefreshToken(t *testing.T) {
jm := auth.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 := auth.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,214 @@
package auth_test
import (
"context"
"fmt"
"testing"
"time"
"github.com/food-ai/backend/internal/auth"
"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) *auth.Service {
jm := auth.NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
return auth.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, loginError := svc.Login(context.Background(), "firebase-token")
if loginError != nil {
t.Fatalf("unexpected error: %v", loginError)
}
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)
_, loginError := svc.Login(context.Background(), "bad-token")
if loginError == 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)
_, loginError := svc.Login(context.Background(), "token")
if loginError == 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)
_, loginError := svc.Login(context.Background(), "token")
if loginError == 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, refreshError := svc.Refresh(context.Background(), "valid-refresh-token")
if refreshError != nil {
t.Fatalf("unexpected error: %v", refreshError)
}
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)
_, refreshError := svc.Refresh(context.Background(), "bad-token")
if refreshError == 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)
_, refreshError := svc.Refresh(context.Background(), "valid-token")
if refreshError == 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)
logoutError := svc.Logout(context.Background(), "user-1")
if logoutError != nil {
t.Fatalf("unexpected error: %v", logoutError)
}
}
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)
logoutError := svc.Logout(context.Background(), "user-1")
if logoutError == 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)
}
}