package services import ( "database/sql" "io" "net/http" "regexp" "strings" "testing" "time" "github.com/DATA-DOG/go-sqlmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" "veza-backend-api/internal/database" ) // Helper to setup mock DB func setupMockDB(t *testing.T) (*database.Database, sqlmock.Sqlmock) { db, mock, err := sqlmock.New() require.NoError(t, err) dbWrapper := &database.Database{ DB: db, Logger: zap.NewNop(), } return dbWrapper, mock } func TestOAuthService_GenerateStateToken_Success(t *testing.T) { // Setup db, mock := setupMockDB(t) defer db.DB.Close() logger := zap.NewNop() service := &OAuthService{ db: db, logger: logger, } provider := "google" redirectURL := "http://example.com" // Expectation mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO oauth_states`)). WithArgs(sqlmock.AnyArg(), provider, redirectURL, sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(1, 1)) // Execute token, err := service.GenerateStateToken(provider, redirectURL) // Assert assert.NoError(t, err) assert.NotEmpty(t, token) assert.NoError(t, mock.ExpectationsWereMet()) } func TestOAuthService_ValidateStateToken_Success(t *testing.T) { // Setup db, mock := setupMockDB(t) defer db.DB.Close() logger := zap.NewNop() service := &OAuthService{ db: db, logger: logger, } token := "valid_token" now := time.Now() // Expectation rows := sqlmock.NewRows([]string{"id", "state_token", "provider", "redirect_url", "expires_at", "created_at"}). AddRow(1, token, "google", "http://example.com", now.Add(time.Hour), now) mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, state_token, provider, redirect_url, expires_at, created_at FROM oauth_states WHERE state_token = $1`)). WithArgs(token). WillReturnRows(rows) mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM oauth_states WHERE id = $1`)). WithArgs(1). WillReturnResult(sqlmock.NewResult(1, 1)) // Execute state, err := service.ValidateStateToken(token) // Assert assert.NoError(t, err) assert.NotNil(t, state) assert.Equal(t, token, state.StateToken) assert.NoError(t, mock.ExpectationsWereMet()) } func TestOAuthService_ValidateStateToken_NotFound(t *testing.T) { // Setup db, mock := setupMockDB(t) defer db.DB.Close() logger := zap.NewNop() service := &OAuthService{ db: db, logger: logger, } token := "invalid_token" // Expectation mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, state_token, provider, redirect_url, expires_at, created_at FROM oauth_states WHERE state_token = $1`)). WithArgs(token). WillReturnError(sql.ErrNoRows) // Execute state, err := service.ValidateStateToken(token) // Assert assert.Error(t, err) assert.Equal(t, "invalid state token", err.Error()) assert.Nil(t, state) assert.NoError(t, mock.ExpectationsWereMet()) } func TestOAuthService_ValidateStateToken_Expired(t *testing.T) { // Setup db, mock := setupMockDB(t) defer db.DB.Close() logger := zap.NewNop() service := &OAuthService{ db: db, logger: logger, } token := "expired_token" now := time.Now() // Expectation rows := sqlmock.NewRows([]string{"id", "state_token", "provider", "redirect_url", "expires_at", "created_at"}). AddRow(1, token, "google", "http://example.com", now.Add(-time.Hour), now.Add(-2*time.Hour)) mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, state_token, provider, redirect_url, expires_at, created_at FROM oauth_states WHERE state_token = $1`)). WithArgs(token). WillReturnRows(rows) // Execute state, err := service.ValidateStateToken(token) // Assert assert.Error(t, err) assert.Equal(t, "state token expired", err.Error()) assert.Nil(t, state) assert.NoError(t, mock.ExpectationsWereMet()) } func TestOAuthService_GetAuthURL_Discord(t *testing.T) { db, mock := setupMockDB(t) defer db.DB.Close() svc := NewOAuthService(db, zap.NewNop(), []byte("secret")) svc.InitializeConfigs("", "", "", "", "discord-client", "discord-secret", "", "", "http://localhost:8080") mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO oauth_states`)). WithArgs(sqlmock.AnyArg(), "discord", "", sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(1, 1)) url, err := svc.GetAuthURL("discord") assert.NoError(t, err) assert.NotEmpty(t, url) assert.Contains(t, url, "discord.com/api/oauth2/authorize") assert.Contains(t, url, "identify") assert.Contains(t, url, "email") assert.Contains(t, url, "discord-client") assert.NoError(t, mock.ExpectationsWereMet()) } func TestOAuthService_GetAuthURL_Spotify(t *testing.T) { db, mock := setupMockDB(t) defer db.DB.Close() svc := NewOAuthService(db, zap.NewNop(), []byte("secret")) svc.InitializeConfigs("", "", "", "", "", "", "spotify-client", "spotify-secret", "http://localhost:8080") mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO oauth_states`)). WithArgs(sqlmock.AnyArg(), "spotify", "", sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(1, 1)) url, err := svc.GetAuthURL("spotify") assert.NoError(t, err) assert.NotEmpty(t, url) assert.Contains(t, url, "accounts.spotify.com/authorize") assert.Contains(t, url, "user-read-email") assert.Contains(t, url, "user-read-private") assert.Contains(t, url, "spotify-client") assert.NoError(t, mock.ExpectationsWereMet()) } func TestOAuthService_GetAvailableProviders(t *testing.T) { db, _ := setupMockDB(t) defer db.DB.Close() svc := NewOAuthService(db, zap.NewNop(), []byte("secret")) providers := svc.GetAvailableProviders() assert.Empty(t, providers) svc.InitializeConfigs("", "", "gid", "gsec", "did", "dsec", "sid", "ssec", "http://localhost") // github, discord, spotify providers = svc.GetAvailableProviders() assert.Contains(t, providers, "github") assert.Contains(t, providers, "discord") assert.Contains(t, providers, "spotify") assert.Len(t, providers, 3) } // mockOAuthTransport mocks HTTP responses for Discord/Spotify user info APIs type mockOAuthTransport struct { discordResponse string spotifyResponse string } func (m *mockOAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { url := req.URL.String() body := "" if strings.Contains(url, "discord.com") { body = m.discordResponse } else if strings.Contains(url, "spotify.com") { body = m.spotifyResponse } return &http.Response{ StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header), }, nil } func TestOAuthService_GetUserInfo_Discord(t *testing.T) { discordJSON := `{"id":"123456","username":"testuser","email":"test@example.com","avatar":"abc123"}` transport := &mockOAuthTransport{discordResponse: discordJSON} client := &http.Client{Transport: transport, Timeout: 5 * time.Second} db, mock := setupMockDB(t) defer db.DB.Close() svc := NewOAuthService(db, zap.NewNop(), []byte("secret")) svc.InitializeConfigs("", "", "", "", "did", "dsec", "", "", "http://localhost") svc.circuitBreaker = NewCircuitBreakerHTTPClient(client, "oauth-test", zap.NewNop()) user, err := svc.getUserInfo("discord", "fake-token") require.NoError(t, err) assert.Equal(t, "123456", user.ProviderID) assert.Equal(t, "testuser", user.Username) assert.Equal(t, "test@example.com", user.Email) assert.Equal(t, "testuser", user.Name) assert.Equal(t, "abc123", user.Avatar) _ = mock } func TestOAuthService_GetUserInfo_Spotify(t *testing.T) { spotifyJSON := `{"id":"spot123","display_name":"Spot User","email":"spot@example.com","images":[{"url":"https://avatar.url"}]}` transport := &mockOAuthTransport{spotifyResponse: spotifyJSON} client := &http.Client{Transport: transport, Timeout: 5 * time.Second} db, _ := setupMockDB(t) defer db.DB.Close() svc := NewOAuthService(db, zap.NewNop(), []byte("secret")) svc.InitializeConfigs("", "", "", "", "", "", "sid", "ssec", "http://localhost") svc.circuitBreaker = NewCircuitBreakerHTTPClient(client, "oauth-test", zap.NewNop()) user, err := svc.getUserInfo("spotify", "fake-token") require.NoError(t, err) assert.Equal(t, "spot123", user.ProviderID) assert.Equal(t, "Spot User", user.Username) assert.Equal(t, "spot@example.com", user.Email) assert.Equal(t, "https://avatar.url", user.Avatar) } func TestOAuthService_GetUserInfo_Spotify_FallbackEmail(t *testing.T) { // Spotify without email - should fallback to id@spotify.user spotifyJSON := `{"id":"spot456","display_name":"","images":[]}` transport := &mockOAuthTransport{spotifyResponse: spotifyJSON} client := &http.Client{Transport: transport, Timeout: 5 * time.Second} db, _ := setupMockDB(t) defer db.DB.Close() svc := NewOAuthService(db, zap.NewNop(), []byte("secret")) svc.InitializeConfigs("", "", "", "", "", "", "sid", "ssec", "http://localhost") svc.circuitBreaker = NewCircuitBreakerHTTPClient(client, "oauth-test", zap.NewNop()) user, err := svc.getUserInfo("spotify", "fake-token") require.NoError(t, err) assert.Equal(t, "spot456", user.ProviderID) assert.Equal(t, "spot456", user.Username) // fallback to ID when display_name empty assert.Equal(t, "spot456@spotify.user", user.Email) }