refactor: [shitty claude AI first try] restructure server and user services, add new test cases, and improve error handling

This commit is contained in:
2025-08-10 21:40:15 +03:00
parent 588576b82f
commit f503e45be1
23 changed files with 2568 additions and 134 deletions

View File

@ -2,10 +2,12 @@ package service
import (
"context"
"net/http"
"strconv"
"git.logidex.ru/fakz9/logidex-id/internal/api/auth/domain"
userDomain "git.logidex.ru/fakz9/logidex-id/internal/api/user/domain"
userService "git.logidex.ru/fakz9/logidex-id/internal/api/user/service"
"git.logidex.ru/fakz9/logidex-id/internal/phoneutil"
hydraApi "github.com/ory/hydra-client-go"
)
@ -18,24 +20,75 @@ type AuthService interface {
type authService struct {
repo domain.AuthRepository
userRepo userDomain.UserRepository
userService userService.UserService
hydraClient *hydraApi.APIClient
}
func (a authService) AcceptConsent(ctx context.Context, phoneNumber string, challenge string) (string, error) {
phoneNumber, err := phoneutil.ParseAndFormatPhoneNumber(phoneNumber)
func (a authService) validateAndFormatPhoneNumber(phoneNumber string) (string, error) {
formattedPhone, err := phoneutil.ParseAndFormatPhoneNumber(phoneNumber)
if err != nil {
return "", domain.ErrInvalidPhoneNumber{
PhoneNumber: phoneNumber,
Err: err,
}
}
user, err := a.userRepo.GetUserByPhoneNumber(ctx, phoneNumber)
return formattedPhone, nil
}
func (a authService) getUserByPhoneNumber(ctx context.Context, phoneNumber string) (*userDomain.User, error) {
user, err := a.userService.GetUserByPhoneNumber(ctx, phoneNumber)
if err != nil {
return nil, err
}
if user == nil {
return nil, domain.ErrUserNotFound{PhoneNumber: phoneNumber}
}
return user, nil
}
func (a authService) getOrCreateUser(ctx context.Context, phoneNumber string) (*userDomain.User, error) {
user, err := a.userService.GetUserByPhoneNumber(ctx, phoneNumber)
if err != nil {
return nil, err
}
if user == nil {
user, err = a.userService.CreateUser(ctx, phoneNumber)
if err != nil {
return nil, err
}
}
return user, nil
}
func (a authService) validateHydraResponse(rawRsp *http.Response, userUuid string) error {
if rawRsp.StatusCode != 200 {
return domain.ErrInvalidHydraAccept{
Message: "Hydra response status: " + strconv.Itoa(rawRsp.StatusCode),
Uuid: userUuid,
}
}
return nil
}
func (a authService) extractRedirectUrl(rsp interface{ GetRedirectToOk() (*string, bool) }, userUuid string) (string, error) {
redirectTo, ok := rsp.GetRedirectToOk()
if !ok || redirectTo == nil {
return "", domain.ErrInvalidHydraAccept{
Message: "Hydra redirectTo is nil",
Uuid: userUuid,
}
}
return *redirectTo, nil
}
func (a authService) AcceptConsent(ctx context.Context, phoneNumber string, challenge string) (string, error) {
phoneNumber, err := a.validateAndFormatPhoneNumber(phoneNumber)
if err != nil {
return "", err
}
if user == nil {
return "", domain.ErrUserNotFound{PhoneNumber: phoneNumber}
user, err := a.getUserByPhoneNumber(ctx, phoneNumber)
if err != nil {
return "", err
}
request := hydraApi.AcceptConsentRequest{}
request.SetGrantScope([]string{"openid"})
@ -50,86 +103,65 @@ func (a authService) AcceptConsent(ctx context.Context, phoneNumber string, chal
if err != nil {
return "", err
}
if rawRsp.StatusCode != 200 {
return "", domain.ErrInvalidHydraAccept{
Message: "Hydra response is nil: " + strconv.Itoa(rawRsp.StatusCode),
Uuid: "",
}
if err = a.validateHydraResponse(rawRsp, user.Uuid); err != nil {
return "", err
}
redirectTo, ok := rsp.GetRedirectToOk()
if !ok || redirectTo == nil {
return "", domain.ErrInvalidHydraAccept{
Message: "Hydra redirectTo is nil",
Uuid: "",
}
}
// TODO: Verify user in the database
_, err = a.userRepo.VerifyUser(ctx, user.Uuid.String())
redirectUrl, err := a.extractRedirectUrl(rsp, user.Uuid)
if err != nil {
return "", err
}
return *redirectTo, nil
// TODO: Verify user in the database
_, err = a.userService.VerifyUser(ctx, user.Uuid)
if err != nil {
return "", err
}
return redirectUrl, nil
}
func (a authService) OtpRequest(ctx context.Context, phoneNumber string) error {
phoneNumber, err := phoneutil.ParseAndFormatPhoneNumber(phoneNumber)
phoneNumber, err := a.validateAndFormatPhoneNumber(phoneNumber)
if err != nil {
return domain.ErrInvalidPhoneNumber{
PhoneNumber: phoneNumber,
Err: err,
}
return err
}
user, err := a.userRepo.GetUserByPhoneNumber(ctx, phoneNumber)
//if err != nil {
// return err
//}
if user == nil {
// Create a new user if it does not exist
user, err = a.userRepo.CreateUser(ctx, phoneNumber)
if err != nil {
return err
}
user, err := a.getOrCreateUser(ctx, phoneNumber)
if err != nil {
return err
}
code := "123456"
err = a.repo.SaveOtpRequest(ctx, user.Uuid.String(), code)
err = a.repo.SaveOtpRequest(ctx, user.Uuid, code)
if err != nil {
return err
}
// TODO implement sending OTP code via SMS
return nil
}
func (a authService) OtpVerify(ctx context.Context, phoneNumber string, code string, loggingChallenge string) (string, error) {
phoneNumber, err := phoneutil.ParseAndFormatPhoneNumber(phoneNumber)
if err != nil {
return "", domain.ErrInvalidPhoneNumber{
PhoneNumber: phoneNumber,
Err: err,
}
}
user, err := a.userRepo.GetUserByPhoneNumber(ctx, phoneNumber)
phoneNumber, err := a.validateAndFormatPhoneNumber(phoneNumber)
if err != nil {
return "", err
}
if user == nil {
return "", domain.ErrUserNotFound{PhoneNumber: phoneNumber}
user, err := a.getUserByPhoneNumber(ctx, phoneNumber)
if err != nil {
return "", err
}
otp, err := a.repo.GetOtpRequest(ctx, user.Uuid.String())
otp, err := a.repo.GetOtpRequest(ctx, user.Uuid)
if err != nil {
return "", err
}
if otp == nil {
return "", domain.ErrOtpNotFound{Uuid: user.Uuid.String()}
return "", domain.ErrOtpNotFound{Uuid: user.Uuid}
}
if *otp != code {
return "", domain.ErrOtpInvalid{Uuid: user.Uuid.String(), Code: code}
return "", domain.ErrOtpInvalid{Uuid: user.Uuid, Code: code}
}
request := hydraApi.AcceptLoginRequest{}
request.SetSubject(user.Uuid.String())
request.SetSubject(user.Uuid)
request.SetRemember(true)
request.SetRememberFor(3600) // 1 hour
@ -141,27 +173,22 @@ func (a authService) OtpVerify(ctx context.Context, phoneNumber string, code str
if err != nil {
return "", err
}
if rawRsp.StatusCode != 200 {
return "", domain.ErrInvalidHydraAccept{
Message: "Hydra response is nil: " + strconv.Itoa(rawRsp.StatusCode),
Uuid: user.Uuid.String(),
}
if err = a.validateHydraResponse(rawRsp, user.Uuid); err != nil {
return "", err
}
redirectTo, ok := rsp.GetRedirectToOk()
if !ok || redirectTo == nil {
return "", domain.ErrInvalidHydraAccept{
Message: "Hydra redirectTo is nil",
Uuid: user.Uuid.String(),
}
redirectUrl, err := a.extractRedirectUrl(rsp, user.Uuid)
if err != nil {
return "", err
}
return *redirectTo, nil
return redirectUrl, nil
}
func NewAuthService(repo domain.AuthRepository, userRepo userDomain.UserRepository, hydraClient *hydraApi.APIClient) AuthService {
func NewAuthService(repo domain.AuthRepository, userService userService.UserService, hydraClient *hydraApi.APIClient) AuthService {
return &authService{
repo: repo,
userRepo: userRepo,
userService: userService,
hydraClient: hydraClient,
}
}

View File

@ -0,0 +1,429 @@
package service
import (
"context"
"net/http"
"testing"
"git.logidex.ru/fakz9/logidex-id/internal/api/auth/domain"
userDomain "git.logidex.ru/fakz9/logidex-id/internal/api/user/domain"
hydraApi "github.com/ory/hydra-client-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// MockAuthRepository implements domain.AuthRepository
type MockAuthRepository struct {
mock.Mock
}
func (m *MockAuthRepository) SaveOtpRequest(ctx context.Context, uuid string, code string) error {
args := m.Called(ctx, uuid, code)
return args.Error(0)
}
func (m *MockAuthRepository) GetOtpRequest(ctx context.Context, uuid string) (*string, error) {
args := m.Called(ctx, uuid)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*string), args.Error(1)
}
// MockUserService implements userService.UserService
type MockUserService struct {
mock.Mock
}
func (m *MockUserService) GetUserByPhoneNumber(ctx context.Context, phoneNumber string) (*userDomain.User, error) {
args := m.Called(ctx, phoneNumber)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*userDomain.User), args.Error(1)
}
func (m *MockUserService) GetUserByUuid(ctx context.Context, uuid string) (*userDomain.User, error) {
args := m.Called(ctx, uuid)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*userDomain.User), args.Error(1)
}
func (m *MockUserService) CreateUser(ctx context.Context, phoneNumber string) (*userDomain.User, error) {
args := m.Called(ctx, phoneNumber)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*userDomain.User), args.Error(1)
}
func (m *MockUserService) VerifyUser(ctx context.Context, uuid string) (*userDomain.User, error) {
args := m.Called(ctx, uuid)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*userDomain.User), args.Error(1)
}
// MockHydraResponse implements hydra response interface
type MockHydraResponse struct {
redirectTo *string
}
func (m *MockHydraResponse) GetRedirectToOk() (*string, bool) {
if m.redirectTo == nil {
return nil, false
}
return m.redirectTo, true
}
func TestNewAuthService(t *testing.T) {
mockRepo := &MockAuthRepository{}
mockUserService := &MockUserService{}
mockHydraClient := &hydraApi.APIClient{}
service := NewAuthService(mockRepo, mockUserService, mockHydraClient)
assert.NotNil(t, service)
assert.Implements(t, (*AuthService)(nil), service)
}
func TestAuthService_getUserByPhoneNumber(t *testing.T) {
mockUserService := &MockUserService{}
service := &authService{userService: mockUserService}
ctx := context.Background()
phoneNumber := "+79161234567"
tests := []struct {
name string
setupMock func()
expectedUser *userDomain.User
wantErr bool
errType interface{}
}{
{
name: "user found",
setupMock: func() {
user := &userDomain.User{
Uuid: "test-uuid",
PhoneNumber: phoneNumber,
}
mockUserService.On("GetUserByPhoneNumber", ctx, phoneNumber).Return(user, nil).Once()
},
expectedUser: &userDomain.User{
Uuid: "test-uuid",
PhoneNumber: phoneNumber,
},
wantErr: false,
},
{
name: "user not found",
setupMock: func() {
mockUserService.On("GetUserByPhoneNumber", ctx, phoneNumber).Return(nil, nil).Once()
},
wantErr: true,
errType: domain.ErrUserNotFound{},
},
{
name: "service error",
setupMock: func() {
mockUserService.On("GetUserByPhoneNumber", ctx, phoneNumber).Return(nil, assert.AnError).Once()
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.setupMock()
result, err := service.getUserByPhoneNumber(ctx, phoneNumber)
if tt.wantErr {
assert.Error(t, err)
assert.Nil(t, result)
if tt.errType != nil {
assert.IsType(t, tt.errType, err)
}
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedUser, result)
}
mockUserService.AssertExpectations(t)
})
}
}
func TestAuthService_getOrCreateUser(t *testing.T) {
mockUserService := &MockUserService{}
service := &authService{userService: mockUserService}
ctx := context.Background()
phoneNumber := "+79161234567"
tests := []struct {
name string
setupMock func()
expectedUser *userDomain.User
wantErr bool
}{
{
name: "existing user found",
setupMock: func() {
user := &userDomain.User{
Uuid: "test-uuid",
PhoneNumber: phoneNumber,
}
mockUserService.On("GetUserByPhoneNumber", ctx, phoneNumber).Return(user, nil).Once()
},
expectedUser: &userDomain.User{
Uuid: "test-uuid",
PhoneNumber: phoneNumber,
},
wantErr: false,
},
{
name: "user not found, create new",
setupMock: func() {
newUser := &userDomain.User{
Uuid: "new-uuid",
PhoneNumber: phoneNumber,
}
mockUserService.On("GetUserByPhoneNumber", ctx, phoneNumber).Return(nil, nil).Once()
mockUserService.On("CreateUser", ctx, phoneNumber).Return(newUser, nil).Once()
},
expectedUser: &userDomain.User{
Uuid: "new-uuid",
PhoneNumber: phoneNumber,
},
wantErr: false,
},
{
name: "get user service error",
setupMock: func() {
mockUserService.On("GetUserByPhoneNumber", ctx, phoneNumber).Return(nil, assert.AnError).Once()
},
wantErr: true,
},
{
name: "create user service error",
setupMock: func() {
mockUserService.On("GetUserByPhoneNumber", ctx, phoneNumber).Return(nil, nil).Once()
mockUserService.On("CreateUser", ctx, phoneNumber).Return(nil, assert.AnError).Once()
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.setupMock()
result, err := service.getOrCreateUser(ctx, phoneNumber)
if tt.wantErr {
assert.Error(t, err)
assert.Nil(t, result)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedUser, result)
}
mockUserService.AssertExpectations(t)
})
}
}
func TestAuthService_validateHydraResponse(t *testing.T) {
service := &authService{}
userUuid := "test-uuid"
tests := []struct {
name string
statusCode int
wantErr bool
errType interface{}
}{
{
name: "success status",
statusCode: 200,
wantErr: false,
},
{
name: "bad request status",
statusCode: 400,
wantErr: true,
errType: domain.ErrInvalidHydraAccept{},
},
{
name: "internal server error status",
statusCode: 500,
wantErr: true,
errType: domain.ErrInvalidHydraAccept{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp := &http.Response{StatusCode: tt.statusCode}
err := service.validateHydraResponse(resp, userUuid)
if tt.wantErr {
assert.Error(t, err)
if tt.errType != nil {
assert.IsType(t, tt.errType, err)
}
} else {
assert.NoError(t, err)
}
})
}
}
func TestAuthService_extractRedirectUrl(t *testing.T) {
service := &authService{}
userUuid := "test-uuid"
tests := []struct {
name string
response *MockHydraResponse
expectedUrl string
wantErr bool
errType interface{}
}{
{
name: "valid redirect url",
response: &MockHydraResponse{
redirectTo: stringPtr("https://example.com/callback"),
},
expectedUrl: "https://example.com/callback",
wantErr: false,
},
{
name: "nil redirect url",
response: &MockHydraResponse{
redirectTo: nil,
},
wantErr: true,
errType: domain.ErrInvalidHydraAccept{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := service.extractRedirectUrl(tt.response, userUuid)
if tt.wantErr {
assert.Error(t, err)
assert.Empty(t, result)
if tt.errType != nil {
assert.IsType(t, tt.errType, err)
}
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedUrl, result)
}
})
}
}
func TestAuthService_OtpRequest(t *testing.T) {
mockRepo := &MockAuthRepository{}
mockUserService := &MockUserService{}
service := &authService{
repo: mockRepo,
userService: mockUserService,
}
ctx := context.Background()
phoneNumber := "89161234567" // will be formatted to +79161234567
tests := []struct {
name string
setupMock func()
wantErr bool
errType interface{}
}{
{
name: "success - existing user",
setupMock: func() {
user := &userDomain.User{
Uuid: "test-uuid",
PhoneNumber: "+79161234567",
}
mockUserService.On("GetUserByPhoneNumber", ctx, "+79161234567").Return(user, nil).Once()
mockRepo.On("SaveOtpRequest", ctx, "test-uuid", "123456").Return(nil).Once()
},
wantErr: false,
},
{
name: "success - create new user",
setupMock: func() {
newUser := &userDomain.User{
Uuid: "new-uuid",
PhoneNumber: "+79161234567",
}
mockUserService.On("GetUserByPhoneNumber", ctx, "+79161234567").Return(nil, nil).Once()
mockUserService.On("CreateUser", ctx, "+79161234567").Return(newUser, nil).Once()
mockRepo.On("SaveOtpRequest", ctx, "new-uuid", "123456").Return(nil).Once()
},
wantErr: false,
},
{
name: "invalid phone number",
setupMock: func() {
// No mock setup needed for invalid phone
},
wantErr: true,
errType: domain.ErrInvalidPhoneNumber{},
},
{
name: "repo save error",
setupMock: func() {
user := &userDomain.User{
Uuid: "test-uuid",
PhoneNumber: "+79161234567",
}
mockUserService.On("GetUserByPhoneNumber", ctx, "+79161234567").Return(user, nil).Once()
mockRepo.On("SaveOtpRequest", ctx, "test-uuid", "123456").Return(assert.AnError).Once()
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset mocks
mockRepo.ExpectedCalls = nil
mockUserService.ExpectedCalls = nil
tt.setupMock()
testPhone := phoneNumber
if tt.name == "invalid phone number" {
testPhone = "invalid"
}
err := service.OtpRequest(ctx, testPhone)
if tt.wantErr {
assert.Error(t, err)
if tt.errType != nil {
assert.IsType(t, tt.errType, err)
}
} else {
assert.NoError(t, err)
}
mockRepo.AssertExpectations(t)
mockUserService.AssertExpectations(t)
})
}
}
// Helper function to create string pointer
func stringPtr(s string) *string {
return &s
}