feat: user service

This commit is contained in:
Ilia Denisov
2026-04-10 19:05:02 +02:00
committed by GitHub
parent 710bad712e
commit 23ffcb7535
140 changed files with 33418 additions and 952 deletions
@@ -0,0 +1,233 @@
package internalhttp
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"galaxy/user/internal/service/accountview"
"galaxy/user/internal/service/adminusers"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestAdminReadHandlersSuccessCases(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, Dependencies{
GetUserByID: getUserByIDFunc(func(_ context.Context, input adminusers.GetUserByIDInput) (adminusers.LookupResult, error) {
require.Equal(t, "user-123", input.UserID)
return adminusers.LookupResult{User: sampleAccountView()}, nil
}),
GetUserByEmail: getUserByEmailFunc(func(_ context.Context, input adminusers.GetUserByEmailInput) (adminusers.LookupResult, error) {
require.Equal(t, "pilot@example.com", input.Email)
return adminusers.LookupResult{User: sampleAccountView()}, nil
}),
GetUserByRaceName: getUserByRaceNameFunc(func(_ context.Context, input adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
require.Equal(t, "Pilot Nova", input.RaceName)
return adminusers.LookupResult{User: sampleAccountView()}, nil
}),
ListUsers: listUsersFunc(func(_ context.Context, input adminusers.ListUsersInput) (adminusers.ListUsersResult, error) {
require.Equal(t, 2, input.PageSize)
require.Equal(t, "cursor-1", input.PageToken)
require.Equal(t, "paid", input.PaidState)
require.Equal(t, "DE", input.DeclaredCountry)
require.Equal(t, "login_block", input.SanctionCode)
require.Equal(t, "max_owned_private_games", input.LimitCode)
require.NotNil(t, input.PaidExpiresBefore)
require.NotNil(t, input.PaidExpiresAfter)
require.NotNil(t, input.CanLogin)
require.NotNil(t, input.CanCreatePrivateGame)
require.NotNil(t, input.CanJoinGame)
require.False(t, *input.CanLogin)
require.True(t, *input.CanCreatePrivateGame)
require.True(t, *input.CanJoinGame)
require.Equal(t, time.Date(2026, time.April, 10, 12, 0, 0, 0, time.UTC), input.PaidExpiresBefore.UTC())
require.Equal(t, time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC), input.PaidExpiresAfter.UTC())
other := sampleAccountView()
other.UserID = "user-234"
other.Email = "second@example.com"
other.RaceName = "Second Pilot"
return adminusers.ListUsersResult{
Items: []accountview.AccountView{sampleAccountView(), other},
NextPageToken: "cursor-2",
}, nil
}),
})
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantBody string
}{
{
name: "get user by id",
method: http.MethodGet,
path: "/api/v1/internal/users/user-123",
wantStatus: http.StatusOK,
wantBody: `{"user":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
},
{
name: "get user by email",
method: http.MethodPost,
path: "/api/v1/internal/user-lookups/by-email",
body: `{"email":"pilot@example.com"}`,
wantStatus: http.StatusOK,
wantBody: `{"user":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
},
{
name: "get user by race name",
method: http.MethodPost,
path: "/api/v1/internal/user-lookups/by-race-name",
body: `{"race_name":"Pilot Nova"}`,
wantStatus: http.StatusOK,
wantBody: `{"user":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
},
{
name: "list users",
method: http.MethodGet,
path: "/api/v1/internal/users?page_size=2&page_token=cursor-1&paid_state=paid&paid_expires_before=2026-04-10T12:00:00Z&paid_expires_after=2026-04-01T12:00:00Z&declared_country=DE&sanction_code=login_block&limit_code=max_owned_private_games&can_login=false&can_create_private_game=true&can_join_game=true",
wantStatus: http.StatusOK,
wantBody: `{"items":[{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},{"user_id":"user-234","email":"second@example.com","race_name":"Second Pilot","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}],"next_page_token":"cursor-2"}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var body *bytes.Buffer
if tt.body != "" {
body = bytes.NewBufferString(tt.body)
} else {
body = &bytes.Buffer{}
}
request := httptest.NewRequest(tt.method, tt.path, body)
if tt.body != "" {
request.Header.Set("Content-Type", "application/json")
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
require.Equal(t, tt.wantStatus, recorder.Code)
assertJSONEq(t, recorder.Body.String(), tt.wantBody)
})
}
}
func TestAdminReadHandlersErrorCases(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, Dependencies{
GetUserByID: getUserByIDFunc(func(context.Context, adminusers.GetUserByIDInput) (adminusers.LookupResult, error) {
return adminusers.LookupResult{}, shared.SubjectNotFound()
}),
GetUserByEmail: getUserByEmailFunc(func(context.Context, adminusers.GetUserByEmailInput) (adminusers.LookupResult, error) {
return adminusers.LookupResult{}, shared.SubjectNotFound()
}),
GetUserByRaceName: getUserByRaceNameFunc(func(context.Context, adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
return adminusers.LookupResult{}, shared.SubjectNotFound()
}),
ListUsers: listUsersFunc(func(context.Context, adminusers.ListUsersInput) (adminusers.ListUsersResult, error) {
return adminusers.ListUsersResult{}, shared.InvalidRequest("page_token is invalid or does not match current filters")
}),
})
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantBody string
}{
{
name: "get user by id not found",
method: http.MethodGet,
path: "/api/v1/internal/users/user-missing",
wantStatus: http.StatusNotFound,
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
},
{
name: "get user by email malformed json",
method: http.MethodPost,
path: "/api/v1/internal/user-lookups/by-email",
body: `{"email":"pilot@example.com","extra":true}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"extra\""}}`,
},
{
name: "get user by race name not found",
method: http.MethodPost,
path: "/api/v1/internal/user-lookups/by-race-name",
body: `{"race_name":"Missing Pilot"}`,
wantStatus: http.StatusNotFound,
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
},
{
name: "list users invalid page size",
method: http.MethodGet,
path: "/api/v1/internal/users?page_size=201",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"page_size must be between 1 and 200"}}`,
},
{
name: "list users invalid timestamp",
method: http.MethodGet,
path: "/api/v1/internal/users?paid_expires_before=not-a-time",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"paid_expires_before must be a valid RFC 3339 timestamp"}}`,
},
{
name: "list users invalid boolean",
method: http.MethodGet,
path: "/api/v1/internal/users?can_login=maybe",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"can_login must be a valid boolean"}}`,
},
{
name: "list users invalid page token",
method: http.MethodGet,
path: "/api/v1/internal/users?page_token=cursor-1",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"page_token is invalid or does not match current filters"}}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var body *bytes.Buffer
if tt.body != "" {
body = bytes.NewBufferString(tt.body)
} else {
body = &bytes.Buffer{}
}
request := httptest.NewRequest(tt.method, tt.path, body)
if tt.body != "" {
request.Header.Set("Content-Type", "application/json")
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
require.Equal(t, tt.wantStatus, recorder.Code)
assertJSONEq(t, recorder.Body.String(), tt.wantBody)
})
}
}