diff --git a/sysken-pay-backend/app/domain/object/user/get_user.go b/sysken-pay-backend/app/domain/object/user/get_user.go new file mode 100644 index 0000000..5a7964c --- /dev/null +++ b/sysken-pay-backend/app/domain/object/user/get_user.go @@ -0,0 +1,34 @@ +package user + +import "time" + +//TODO モデル(データベースから取得する型を宣言する) +//データベースの制約通りになるようにエラーハンドリングをガチる +//ユーザーID、名前、作成日時、更新日時など + +func NewUserFromDB( + userID string, + userName string, + createdAt time.Time, + updatedAt time.Time, + deletedAt time.Time, +) (*User, error) { + user, err := NewUser(userID, userName) + if err != nil { + return nil, err + } + + if err := user.SetCreatedAt(createdAt); err != nil { + return nil, err + } + if err := user.SetUpdatedAt(updatedAt); err != nil { + return nil, err + } + if !deletedAt.IsZero() { + if err := user.SetDeletedAt(deletedAt); err != nil { + return nil, err + } + } + + return user, nil +} diff --git a/sysken-pay-backend/app/domain/object/user/user.go b/sysken-pay-backend/app/domain/object/user/user.go index e4f434e..4d49f33 100644 --- a/sysken-pay-backend/app/domain/object/user/user.go +++ b/sysken-pay-backend/app/domain/object/user/user.go @@ -2,10 +2,17 @@ package user import ( "errors" + "regexp" "time" "unicode/utf8" ) +// 学籍番号フォーマット: 2桁の数字 + 1文字のアルファベット + 数字 (例: 20K23099) +var userIDPattern = regexp.MustCompile(`^\d{2}[A-Za-z]\d+$`) + +// ErrUserAlreadyExists は同じIDのユーザーが既に登録されている場合に返されます。 +var ErrUserAlreadyExists = errors.New("user already exists") + //TODO モデル(データベースに入れる型を宣言する) //データベースの制約通りになるようにエラーハンドリングをガチる //ユーザーID、名前、作成日時、更新日時など @@ -27,6 +34,10 @@ func (p *User) SetUserID(userID string) error { if utf8.RuneCountInString(userID) > 20 { return errors.New("userID must be 20 characters or less") } + // 学籍番号フォーマット (例: 20K23099) + if !userIDPattern.MatchString(userID) { + return errors.New("userID must be in student ID format (e.g., 20K23099)") + } p.userID = userID return nil diff --git a/sysken-pay-backend/app/domain/object/user/user_test.go b/sysken-pay-backend/app/domain/object/user/user_test.go index 9543afb..5bf191b 100644 --- a/sysken-pay-backend/app/domain/object/user/user_test.go +++ b/sysken-pay-backend/app/domain/object/user/user_test.go @@ -9,7 +9,7 @@ import ( func TestSetUserID_Valid(t *testing.T) { u := &User{} - if err := u.SetUserID("user123"); err != nil { + if err := u.SetUserID("20K23099"); err != nil { t.Errorf("SetUserID should succeed: %v", err) } } @@ -23,7 +23,8 @@ func TestSetUserID_Empty(t *testing.T) { func TestSetUserID_Exactly20Chars(t *testing.T) { u := &User{} - id := strings.Repeat("a", 20) + // 学籍番号フォーマットで20文字 (2桁数字 + 1文字 + 17桁数字) + id := "20K" + strings.Repeat("1", 17) if err := u.SetUserID(id); err != nil { t.Errorf("SetUserID 20 chars should succeed: %v", err) } @@ -31,23 +32,28 @@ func TestSetUserID_Exactly20Chars(t *testing.T) { func TestSetUserID_Over20Chars(t *testing.T) { u := &User{} - id := strings.Repeat("a", 21) + id := "20K" + strings.Repeat("1", 18) // 21 chars if err := u.SetUserID(id); err == nil { t.Error("SetUserID 21 chars should fail") } } -func TestSetUserID_MultibyteCounts(t *testing.T) { +func TestSetUserID_InvalidFormat(t *testing.T) { u := &User{} - // 日本語20文字はOK - id := strings.Repeat("あ", 20) - if err := u.SetUserID(id); err != nil { - t.Errorf("SetUserID 20 multibyte chars should succeed: %v", err) - } - // 21文字はNG - id21 := strings.Repeat("あ", 21) - if err := u.SetUserID(id21); err == nil { - t.Error("SetUserID 21 multibyte chars should fail") + cases := []string{ + "abcdef", // アルファベットのみ + "user123", // 数字2桁スタートでない + "123", // 全数字 + "20K", // 末尾の数字なし + "20KK23099", // アルファベット2文字 + "K20K23099", // 先頭が数字でない + "2-K23099", // 記号混入 + strings.Repeat("あ", 8), // マルチバイトはフォーマット違反 + } + for _, id := range cases { + if err := u.SetUserID(id); err == nil { + t.Errorf("SetUserID(%q) should fail format validation", id) + } } } @@ -98,12 +104,12 @@ func TestSetUserName_MultibyteCounts(t *testing.T) { // --- NewUser --- func TestNewUser_Valid(t *testing.T) { - u, err := NewUser("student001", "田中 太郎") + u, err := NewUser("20K23099", "田中 太郎") if err != nil { t.Fatalf("NewUser should succeed: %v", err) } - if u.ID() != "student001" { - t.Errorf("ID() = %s, want student001", u.ID()) + if u.ID() != "20K23099" { + t.Errorf("ID() = %s, want 20K23099", u.ID()) } if u.UserName() != "田中 太郎" { t.Errorf("UserName() = %s, want 田中 太郎", u.UserName()) @@ -117,19 +123,20 @@ func TestNewUser_EmptyUserID(t *testing.T) { } func TestNewUser_EmptyUserName(t *testing.T) { - if _, err := NewUser("student001", ""); err == nil { + if _, err := NewUser("20K23099", ""); err == nil { t.Error("NewUser empty userName should fail") } } func TestNewUser_UserIDTooLong(t *testing.T) { - if _, err := NewUser(strings.Repeat("a", 21), "田中 太郎"); err == nil { + id := "20K" + strings.Repeat("1", 18) // 21 chars + if _, err := NewUser(id, "田中 太郎"); err == nil { t.Error("NewUser userID > 20 chars should fail") } } func TestNewUser_UserNameTooLong(t *testing.T) { - if _, err := NewUser("student001", strings.Repeat("a", 51)); err == nil { + if _, err := NewUser("20K23099", strings.Repeat("a", 51)); err == nil { t.Error("NewUser userName > 50 chars should fail") } } diff --git a/sysken-pay-backend/app/domain/repository/user.go b/sysken-pay-backend/app/domain/repository/user.go index f7d974d..254c9ea 100644 --- a/sysken-pay-backend/app/domain/repository/user.go +++ b/sysken-pay-backend/app/domain/repository/user.go @@ -9,6 +9,9 @@ import ( //データベースで必要な入力と出力のインターフェースの作成 type UserRepository interface { + // ユーザーIDでユーザー情報を取得する + GetUserByID(ctx context.Context, userID string) (*user.User, error) + // ユーザーを新規作成して保存する // 保存に成功した場合は保存したユーザーを返す InsertUser(ctx context.Context, u *user.User) (*user.User, error) diff --git a/sysken-pay-backend/app/infra/repository/user.go b/sysken-pay-backend/app/infra/repository/user.go index 1dbb06c..c05866a 100644 --- a/sysken-pay-backend/app/infra/repository/user.go +++ b/sysken-pay-backend/app/infra/repository/user.go @@ -3,12 +3,18 @@ package repository import ( "context" "database/sql" + "errors" "log" "sysken-pay-api/app/domain/object/user" "sysken-pay-api/app/domain/repository" "time" + + "github.com/go-sql-driver/mysql" ) +// mysqlErrDupEntry は重複キー違反 (Duplicate entry) を示す MySQL のエラー番号です。 +const mysqlErrDupEntry = 1062 + //TODO userデータベースに値を入れる // domainのrepositoryの中にあるユーザーのインターフェースの実装をする @@ -22,6 +28,37 @@ func NewUserProfileRepository(db *sql.DB) *UserRepositoryImpl { return &UserRepositoryImpl{db: db} } +func (r *UserRepositoryImpl) GetUserByID(ctx context.Context, userID string) (*user.User, error) { + executor := getExecutor(ctx, r.db) + + row := executor.QueryRowContext(ctx, ` + SELECT id, name, created_at, updated_at, deleted_at + FROM `+"`user`"+` + WHERE id = ? AND deleted_at IS NULL + `, userID) + + var ( + id string + name string + createdAt time.Time + updatedAt time.Time + deletedAt sql.NullTime + ) + if err := row.Scan(&id, &name, &createdAt, &updatedAt, &deletedAt); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + + var deleted time.Time + if deletedAt.Valid { + deleted = deletedAt.Time + } + + return user.NewUserFromDB(id, name, createdAt, updatedAt, deleted) +} + func (r *UserRepositoryImpl) InsertUser( ctx context.Context, u *user.User) (*user.User, error) { @@ -37,6 +74,10 @@ func (r *UserRepositoryImpl) InsertUser( ) if err != nil { + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) && mysqlErr.Number == mysqlErrDupEntry { + return nil, user.ErrUserAlreadyExists + } log.Printf("Failed to insert user: %v", err) return nil, err } diff --git a/sysken-pay-backend/app/server/server.go b/sysken-pay-backend/app/server/server.go index db4c643..efb650c 100644 --- a/sysken-pay-backend/app/server/server.go +++ b/sysken-pay-backend/app/server/server.go @@ -56,6 +56,7 @@ func Run(db *sql.DB) error { balanceRepo := repository.NewBalanceRepository(db) // UseCase + getUserUseCase := user.NewGetUserUseCase(userRepo) registerUserUseCase := user.NewRegisterUserUseCase(userRepo) updateUserUseCase := user.NewUpdateUserUseCase(userRepo) registerItemUseCase := item.NewRegisterItemUseCase(itemRepo) @@ -70,7 +71,7 @@ func Run(db *sql.DB) error { getPurchaseHistoriesUseCase := balance.NewGetPurchaseHistoriesUseCase(balanceRepo) // Handler - userHandler := api_user.NewUserHandler(registerUserUseCase, updateUserUseCase) + userHandler := api_user.NewUserHandler(getUserUseCase, registerUserUseCase, updateUserUseCase) itemHandler := api_item.NewItemHandler(registerItemUseCase, updateItemUseCase, findItemByJanCodeUseCase, getAllItemsUseCase) chargeHandler := api_charge.NewChargeHandler(chargeAmountUseCase, chargeCancelUseCase) purchaseHandler := api_purchase.NewPurchaseHandler(createPurchaseUseCase, cancelPurchaseUseCase) @@ -93,8 +94,9 @@ func Run(db *sql.DB) error { r.Route("/v1", func(r chi.Router) { // ユーザー関連 r.Post("/user", userHandler.RegisterUser) - r.Patch("/user/{user_id}", userHandler.UpdateUser) r.Route("/user/{user_id}", func(r chi.Router) { + r.Get("/", userHandler.GetUser) + r.Patch("/", userHandler.UpdateUser) r.Post("/charge", chargeHandler.ChargeAmount) r.Post("/charge/cancel", chargeHandler.ChargeCancel) r.Post("/purchase", purchaseHandler.CreatePurchase) diff --git a/sysken-pay-backend/app/ui/api/balance/response.go b/sysken-pay-backend/app/ui/api/balance/response.go index 09709d1..e4cf87f 100644 --- a/sysken-pay-backend/app/ui/api/balance/response.go +++ b/sysken-pay-backend/app/ui/api/balance/response.go @@ -2,6 +2,7 @@ package balance import ( domainbalance "sysken-pay-api/app/domain/object/balance" + "sysken-pay-api/app/ui/api/pkg/timefmt" "sysken-pay-api/app/usecase/balance" ) @@ -50,7 +51,7 @@ func toGetPurchaseHistoriesResponse( ItemName: h.ItemName(), Quantity: h.Quantity(), Price: h.Price(), - PurchaseAt: h.PurchaseAt().Format("2006-01-02T15:04:05.000Z"), + PurchaseAt: timefmt.JST(h.PurchaseAt()), } } return &GetPurchaseHistoriesResponse{ diff --git a/sysken-pay-backend/app/ui/api/charge/charge.go b/sysken-pay-backend/app/ui/api/charge/charge.go index ddf1125..1f3d0aa 100644 --- a/sysken-pay-backend/app/ui/api/charge/charge.go +++ b/sysken-pay-backend/app/ui/api/charge/charge.go @@ -59,8 +59,8 @@ func (h *chargeHandlerImpl) ChargeAmount(w http.ResponseWriter, r *http.Request) //レスポンスの作成 res := toPostChargeResponse(chargedAmount) - w.Header().Set("Content -Type", "application/json") - w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(res); err != nil { log.Printf("Failed to encode response: %v", err) apierrors.RespondError(w, http.StatusInternalServerError, err.Error()) diff --git a/sysken-pay-backend/app/ui/api/charge/response.go b/sysken-pay-backend/app/ui/api/charge/response.go index 8fd8ddd..f3cf21f 100644 --- a/sysken-pay-backend/app/ui/api/charge/response.go +++ b/sysken-pay-backend/app/ui/api/charge/response.go @@ -2,6 +2,7 @@ package charge import ( "sysken-pay-api/app/domain/object/charge" + "sysken-pay-api/app/ui/api/pkg/timefmt" ) type ChargeResponse struct { @@ -20,7 +21,7 @@ func toPostChargeResponse(charge *charge.Charge) *ChargeResponse { Amount: charge.Amount(), UserID: charge.UserID(), Balance: charge.Balance(), - CreatedAt: charge.CreatedAt().Format("2006-01-02T15:04:05.000Z"), + CreatedAt: timefmt.JST(charge.CreatedAt()), } } @@ -40,6 +41,6 @@ func toPostChargeCancelResponse(charge *charge.Charge) *ChargeCancelResponse { Amount: charge.Amount(), UserID: charge.UserID(), Balance: charge.Balance(), - CreatedAt: charge.CreatedAt().Format("2006-01-02T15:04:05.000Z"), + CreatedAt: timefmt.JST(charge.CreatedAt()), } } diff --git a/sysken-pay-backend/app/ui/api/item/item.go b/sysken-pay-backend/app/ui/api/item/item.go index 6090cbd..e32c98f 100644 --- a/sysken-pay-backend/app/ui/api/item/item.go +++ b/sysken-pay-backend/app/ui/api/item/item.go @@ -59,7 +59,7 @@ func (h *itemHandlerImpl) ResisterItem(w http.ResponseWriter, r *http.Request) { res := toPostItemResponse(createdItem) w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(res); err != nil { apierrors.RespondError(w, http.StatusBadRequest, err.Error()) return diff --git a/sysken-pay-backend/app/ui/api/item/response.go b/sysken-pay-backend/app/ui/api/item/response.go index 61bc26a..504a538 100644 --- a/sysken-pay-backend/app/ui/api/item/response.go +++ b/sysken-pay-backend/app/ui/api/item/response.go @@ -1,6 +1,9 @@ package item -import "sysken-pay-api/app/domain/object/item" +import ( + "sysken-pay-api/app/domain/object/item" + "sysken-pay-api/app/ui/api/pkg/timefmt" +) // PostItemResponse Response: 商品登録のレスポンス type PostItemResponse struct { @@ -20,8 +23,8 @@ func toPostItemResponse(item *item.Item) *PostItemResponse { JanCode: item.JanCode(), ItemName: item.Name(), Price: item.Price(), - CreatedAt: item.CreatedAt().Format("2006-01-02T15:04:05.000Z"), - UpdatedAt: item.UpdatedAt().Format("2006-01-02T15:04:05.000Z"), + CreatedAt: timefmt.JST(item.CreatedAt()), + UpdatedAt: timefmt.JST(item.UpdatedAt()), } } @@ -43,8 +46,8 @@ func toPatchItemResponse(item *item.Item) *PatchItemResponse { JanCode: item.JanCode(), ItemName: item.Name(), Price: item.Price(), - CreatedAt: item.CreatedAt().Format("2006-01-02T15:04:05.000Z"), - UpdatedAt: item.UpdatedAt().Format("2006-01-02T15:04:05.000Z"), + CreatedAt: timefmt.JST(item.CreatedAt()), + UpdatedAt: timefmt.JST(item.UpdatedAt()), } } diff --git a/sysken-pay-backend/app/ui/api/pkg/timefmt/timefmt.go b/sysken-pay-backend/app/ui/api/pkg/timefmt/timefmt.go new file mode 100644 index 0000000..9ade952 --- /dev/null +++ b/sysken-pay-backend/app/ui/api/pkg/timefmt/timefmt.go @@ -0,0 +1,11 @@ +package timefmt + +import "time" + +var jst = time.FixedZone("JST", 9*60*60) + +// JST formats t in JST (Asia/Tokyo, +09:00) with millisecond precision. +// 例: 2025-01-01T09:00:00.000+09:00 +func JST(t time.Time) string { + return t.In(jst).Format("2006-01-02T15:04:05.000-07:00") +} diff --git a/sysken-pay-backend/app/ui/api/purchase/purchase.go b/sysken-pay-backend/app/ui/api/purchase/purchase.go index 1108923..9aaa9f2 100644 --- a/sysken-pay-backend/app/ui/api/purchase/purchase.go +++ b/sysken-pay-backend/app/ui/api/purchase/purchase.go @@ -59,6 +59,7 @@ func (h *purchaseHandlerImpl) CreatePurchase(w http.ResponseWriter, r *http.Requ res := toPostPurchaseResponse(createdPurchase) w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(res); err != nil { apierrors.RespondError(w, http.StatusInternalServerError, err.Error()) } diff --git a/sysken-pay-backend/app/ui/api/purchase/response.go b/sysken-pay-backend/app/ui/api/purchase/response.go index 1d8e8cf..710c7c7 100644 --- a/sysken-pay-backend/app/ui/api/purchase/response.go +++ b/sysken-pay-backend/app/ui/api/purchase/response.go @@ -2,6 +2,7 @@ package purchase import ( "sysken-pay-api/app/domain/object/purchase" + "sysken-pay-api/app/ui/api/pkg/timefmt" ) type PostPurchaseResponse struct { @@ -60,6 +61,6 @@ func toPostPurchaseCancelResponse(p *purchase.Purchase) *PostPurchaseCancelRespo ID: p.ID(), UserID: p.UserID(), Items: items, - CreatedAt: p.CreatedAt().Format("2006-01-02T15:04:05.000Z"), + CreatedAt: timefmt.JST(p.CreatedAt()), } } diff --git a/sysken-pay-backend/app/ui/api/user/response.go b/sysken-pay-backend/app/ui/api/user/response.go index 3ae110d..0f5b1e0 100644 --- a/sysken-pay-backend/app/ui/api/user/response.go +++ b/sysken-pay-backend/app/ui/api/user/response.go @@ -2,6 +2,7 @@ package user import ( "sysken-pay-api/app/domain/object/user" + "sysken-pay-api/app/ui/api/pkg/timefmt" ) type PostUserResponse struct { @@ -16,7 +17,23 @@ func toPostUserResponse(user *user.User) *PostUserResponse { Status: "success", UserID: user.ID(), UserName: user.UserName(), - CreatedAt: user.CreatedAt().Format("2006-01-02T15:04:05.000Z"), + CreatedAt: timefmt.JST(user.CreatedAt()), + } +} + +type GetUserResponse struct { + Status string `json:"status"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` + CreatedAt string `json:"created_at"` +} + +func toGetUserResponse(user *user.User) *GetUserResponse { + return &GetUserResponse{ + Status: "success", + UserID: user.ID(), + UserName: user.UserName(), + CreatedAt: timefmt.JST(user.CreatedAt()), } } @@ -32,6 +49,6 @@ func toPatchUserResponse(user *user.User) *PatchUserResponse { Status: "success", UserID: user.ID(), UserName: user.UserName(), - CreatedAt: user.CreatedAt().Format("2006-01-02T15:04:05.000Z"), + CreatedAt: timefmt.JST(user.CreatedAt()), } } diff --git a/sysken-pay-backend/app/ui/api/user/user.go b/sysken-pay-backend/app/ui/api/user/user.go index 22198cd..541bfe8 100644 --- a/sysken-pay-backend/app/ui/api/user/user.go +++ b/sysken-pay-backend/app/ui/api/user/user.go @@ -2,8 +2,10 @@ package user import ( "encoding/json" + "errors" "log" "net/http" + domainuser "sysken-pay-api/app/domain/object/user" apierrors "sysken-pay-api/app/ui/api/pkg/errors" "sysken-pay-api/app/usecase/user" @@ -12,12 +14,18 @@ import ( // TODO APIリクエストからデータを整形してユースケースに情報を渡すものを作成する type Handler interface { + GetUser(w http.ResponseWriter, r *http.Request) RegisterUser(w http.ResponseWriter, r *http.Request) UpdateUser(w http.ResponseWriter, r *http.Request) } -func NewUserHandler(registerUserUseCase user.RegisterUserUseCase, updateUserUseCase user.UpdateUserUseCase) Handler { +func NewUserHandler( + getUserUseCase user.GetUserUseCase, + registerUserUseCase user.RegisterUserUseCase, + updateUserUseCase user.UpdateUserUseCase, +) Handler { return &userHandlerImpl{ + getUserUseCase: getUserUseCase, registerUserUseCase: registerUserUseCase, updateUserUseCase: updateUserUseCase, } @@ -26,10 +34,45 @@ func NewUserHandler(registerUserUseCase user.RegisterUserUseCase, updateUserUseC var _ Handler = (*userHandlerImpl)(nil) type userHandlerImpl struct { + getUserUseCase user.GetUserUseCase registerUserUseCase user.RegisterUserUseCase updateUserUseCase user.UpdateUserUseCase } +func (h *userHandlerImpl) GetUser(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "user_id") + if userID == "" { + log.Printf("user_id is missing in URL") + apierrors.RespondError(w, http.StatusBadRequest, "user_id is required") + return + } + + ctx := r.Context() + foundUser, err := h.getUserUseCase.GetUser(ctx, userID) + if err != nil { + log.Printf("Failed to get user: %v", err) + if errors.Is(err, user.ErrInvalidUserID) { + apierrors.RespondError(w, http.StatusBadRequest, err.Error()) + return + } + apierrors.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if foundUser == nil { + apierrors.RespondError(w, http.StatusNotFound, "user not found") + return + } + + res := toGetUserResponse(foundUser) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(res); err != nil { + apierrors.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } +} + func (h *userHandlerImpl) RegisterUser(w http.ResponseWriter, r *http.Request) { //userRequestのパース var req PostUserRequest @@ -44,6 +87,10 @@ func (h *userHandlerImpl) RegisterUser(w http.ResponseWriter, r *http.Request) { createdUser, err := h.registerUserUseCase.RegisterUser(ctx, req.UserID, req.UserName) if err != nil { log.Printf("Failed to register user: %v", err) + if errors.Is(err, domainuser.ErrUserAlreadyExists) { + apierrors.RespondError(w, http.StatusConflict, "user already exists") + return + } apierrors.RespondError(w, http.StatusInternalServerError, err.Error()) return } @@ -52,7 +99,7 @@ func (h *userHandlerImpl) RegisterUser(w http.ResponseWriter, r *http.Request) { res := toPostUserResponse(createdUser) w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(res); err != nil { apierrors.RespondError(w, http.StatusBadRequest, err.Error()) return diff --git a/sysken-pay-backend/app/usecase/user/get_user.go b/sysken-pay-backend/app/usecase/user/get_user.go new file mode 100644 index 0000000..a5102f7 --- /dev/null +++ b/sysken-pay-backend/app/usecase/user/get_user.go @@ -0,0 +1,30 @@ +package user + +import ( + "context" + "errors" + "fmt" + domainuser "sysken-pay-api/app/domain/object/user" + "sysken-pay-api/app/domain/repository" +) + +var ErrInvalidUserID = errors.New("invalid userID") + +type GetUserUseCase interface { + GetUser(ctx context.Context, userID string) (*domainuser.User, error) +} + +type GetUserServiceImpl struct { + userRepo repository.UserRepository +} + +func NewGetUserUseCase(userRepo repository.UserRepository) *GetUserServiceImpl { + return &GetUserServiceImpl{userRepo: userRepo} +} + +func (s *GetUserServiceImpl) GetUser(ctx context.Context, userID string) (*domainuser.User, error) { + if err := (&domainuser.User{}).SetUserID(userID); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidUserID, err) + } + return s.userRepo.GetUserByID(ctx, userID) +} diff --git a/sysken-pay-backend/app/usecase/user/user_test.go b/sysken-pay-backend/app/usecase/user/user_test.go index 0883d89..7baa703 100644 --- a/sysken-pay-backend/app/usecase/user/user_test.go +++ b/sysken-pay-backend/app/usecase/user/user_test.go @@ -12,10 +12,15 @@ import ( // --- mock --- type mockUserRepo struct { + getFunc func(ctx context.Context, userID string) (*domainuser.User, error) insertFunc func(ctx context.Context, u *domainuser.User) (*domainuser.User, error) updateFunc func(ctx context.Context, u *domainuser.User) (*domainuser.User, error) } +func (m *mockUserRepo) GetUserByID(ctx context.Context, userID string) (*domainuser.User, error) { + return m.getFunc(ctx, userID) +} + func (m *mockUserRepo) InsertUser(ctx context.Context, u *domainuser.User) (*domainuser.User, error) { return m.insertFunc(ctx, u) } @@ -24,6 +29,86 @@ func (m *mockUserRepo) UpdateUser(ctx context.Context, u *domainuser.User) (*dom return m.updateFunc(ctx, u) } +// --- GetUser --- + +func TestGetUser_Success(t *testing.T) { + want, err := domainuser.NewUser("20K23099", "田中 太郎") + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + repo := &mockUserRepo{ + getFunc: func(_ context.Context, userID string) (*domainuser.User, error) { + if userID != "20K23099" { + t.Errorf("userID = %s, want 20K23099", userID) + } + return want, nil + }, + } + uc := NewGetUserUseCase(repo) + result, err := uc.GetUser(context.Background(), "20K23099") + if err != nil { + t.Fatalf("GetUser should succeed: %v", err) + } + if result.ID() != "20K23099" { + t.Errorf("ID() = %s, want 20K23099", result.ID()) + } + if result.UserName() != "田中 太郎" { + t.Errorf("UserName() = %s, want 田中 太郎", result.UserName()) + } +} + +func TestGetUser_NotFound(t *testing.T) { + repo := &mockUserRepo{ + getFunc: func(_ context.Context, userID string) (*domainuser.User, error) { + return nil, nil + }, + } + uc := NewGetUserUseCase(repo) + result, err := uc.GetUser(context.Background(), "20K23099") + if err != nil { + t.Fatalf("GetUser should not fail when user is not found: %v", err) + } + if result != nil { + t.Error("GetUser should return nil when user is not found") + } +} + +func TestGetUser_EmptyUserID(t *testing.T) { + repo := &mockUserRepo{} + uc := NewGetUserUseCase(repo) + if _, err := uc.GetUser(context.Background(), ""); err == nil { + t.Error("GetUser with empty userID should fail") + } +} + +func TestGetUser_UserIDTooLong(t *testing.T) { + repo := &mockUserRepo{} + uc := NewGetUserUseCase(repo) + if _, err := uc.GetUser(context.Background(), strings.Repeat("a", 21)); err == nil { + t.Error("GetUser with userID > 20 chars should fail") + } +} + +func TestGetUser_ItemID(t *testing.T) { + repo := &mockUserRepo{} + uc := NewGetUserUseCase(repo) + if _, err := uc.GetUser(context.Background(), "1"); err == nil { + t.Error("GetUser with itemID should fail") + } +} + +func TestGetUser_RepoError(t *testing.T) { + repo := &mockUserRepo{ + getFunc: func(_ context.Context, userID string) (*domainuser.User, error) { + return nil, errors.New("db error") + }, + } + uc := NewGetUserUseCase(repo) + if _, err := uc.GetUser(context.Background(), "20K23099"); err == nil { + t.Error("GetUser should propagate repo error") + } +} + // --- RegisterUser --- func TestRegisterUser_Success(t *testing.T) { @@ -33,12 +118,12 @@ func TestRegisterUser_Success(t *testing.T) { }, } uc := NewRegisterUserUseCase(repo) - result, err := uc.RegisterUser(context.Background(), "student001", "田中 太郎") + result, err := uc.RegisterUser(context.Background(), "20K23099", "田中 太郎") if err != nil { t.Fatalf("RegisterUser should succeed: %v", err) } - if result.ID() != "student001" { - t.Errorf("ID() = %s, want student001", result.ID()) + if result.ID() != "20K23099" { + t.Errorf("ID() = %s, want 20K23099", result.ID()) } if result.UserName() != "田中 太郎" { t.Errorf("UserName() = %s, want 田中 太郎", result.UserName()) @@ -56,7 +141,7 @@ func TestRegisterUser_EmptyUserID(t *testing.T) { func TestRegisterUser_EmptyUserName(t *testing.T) { repo := &mockUserRepo{} uc := NewRegisterUserUseCase(repo) - if _, err := uc.RegisterUser(context.Background(), "student001", ""); err == nil { + if _, err := uc.RegisterUser(context.Background(), "20K23099", ""); err == nil { t.Error("RegisterUser with empty userName should fail") } } @@ -72,7 +157,7 @@ func TestRegisterUser_UserIDTooLong(t *testing.T) { func TestRegisterUser_UserNameTooLong(t *testing.T) { repo := &mockUserRepo{} uc := NewRegisterUserUseCase(repo) - if _, err := uc.RegisterUser(context.Background(), "student001", strings.Repeat("a", 51)); err == nil { + if _, err := uc.RegisterUser(context.Background(), "20K23099", strings.Repeat("a", 51)); err == nil { t.Error("RegisterUser with userName > 50 chars should fail") } } @@ -84,11 +169,24 @@ func TestRegisterUser_RepoError(t *testing.T) { }, } uc := NewRegisterUserUseCase(repo) - if _, err := uc.RegisterUser(context.Background(), "student001", "田中 太郎"); err == nil { + if _, err := uc.RegisterUser(context.Background(), "20K23099", "田中 太郎"); err == nil { t.Error("RegisterUser should propagate repo error") } } +func TestRegisterUser_AlreadyExists(t *testing.T) { + repo := &mockUserRepo{ + insertFunc: func(_ context.Context, u *domainuser.User) (*domainuser.User, error) { + return nil, domainuser.ErrUserAlreadyExists + }, + } + uc := NewRegisterUserUseCase(repo) + _, err := uc.RegisterUser(context.Background(), "20K23099", "田中 太郎") + if !errors.Is(err, domainuser.ErrUserAlreadyExists) { + t.Errorf("RegisterUser should return ErrUserAlreadyExists, got: %v", err) + } +} + func TestRegisterUser_MaxLengthUserID(t *testing.T) { repo := &mockUserRepo{ insertFunc: func(_ context.Context, u *domainuser.User) (*domainuser.User, error) { @@ -96,7 +194,8 @@ func TestRegisterUser_MaxLengthUserID(t *testing.T) { }, } uc := NewRegisterUserUseCase(repo) - if _, err := uc.RegisterUser(context.Background(), strings.Repeat("a", 20), "田中 太郎"); err != nil { + id := "20K" + strings.Repeat("1", 17) // 20 chars in valid format + if _, err := uc.RegisterUser(context.Background(), id, "田中 太郎"); err != nil { t.Errorf("RegisterUser with 20-char userID should succeed: %v", err) } } @@ -110,12 +209,12 @@ func TestUpdateUser_Success(t *testing.T) { }, } uc := NewUpdateUserUseCase(repo) - result, err := uc.UpdateUser(context.Background(), "student001", "佐藤 花子") + result, err := uc.UpdateUser(context.Background(), "20K23099", "佐藤 花子") if err != nil { t.Fatalf("UpdateUser should succeed: %v", err) } - if result.ID() != "student001" { - t.Errorf("ID() = %s, want student001", result.ID()) + if result.ID() != "20K23099" { + t.Errorf("ID() = %s, want 20K23099", result.ID()) } if result.UserName() != "佐藤 花子" { t.Errorf("UserName() = %s, want 佐藤 花子", result.UserName()) @@ -125,7 +224,7 @@ func TestUpdateUser_Success(t *testing.T) { func TestUpdateUser_EmptyUserName(t *testing.T) { repo := &mockUserRepo{} uc := NewUpdateUserUseCase(repo) - if _, err := uc.UpdateUser(context.Background(), "student001", ""); err == nil { + if _, err := uc.UpdateUser(context.Background(), "20K23099", ""); err == nil { t.Error("UpdateUser with empty userName should fail") } } @@ -141,7 +240,7 @@ func TestUpdateUser_EmptyUserID(t *testing.T) { func TestUpdateUser_UserNameTooLong(t *testing.T) { repo := &mockUserRepo{} uc := NewUpdateUserUseCase(repo) - if _, err := uc.UpdateUser(context.Background(), "student001", strings.Repeat("a", 51)); err == nil { + if _, err := uc.UpdateUser(context.Background(), "20K23099", strings.Repeat("a", 51)); err == nil { t.Error("UpdateUser with userName > 50 chars should fail") } } @@ -153,7 +252,7 @@ func TestUpdateUser_RepoError(t *testing.T) { }, } uc := NewUpdateUserUseCase(repo) - if _, err := uc.UpdateUser(context.Background(), "student001", "田中 太郎"); err == nil { + if _, err := uc.UpdateUser(context.Background(), "20K23099", "田中 太郎"); err == nil { t.Error("UpdateUser should propagate repo error") } } diff --git a/sysken-pay-backend/db/init.sql b/sysken-pay-backend/db/init.sql index 5624f3b..6e3a5d5 100644 --- a/sysken-pay-backend/db/init.sql +++ b/sysken-pay-backend/db/init.sql @@ -8,7 +8,8 @@ USE sysken_pay; -- 1. user テーブル (ユーザー) -- --------------------------------- CREATE TABLE `user` ( - id VARCHAR(20) NOT NULL, + -- 学籍番号フォーマット: 2桁の入学年度数字 + 1文字のアルファベット + 数字の連番 (例: 20K23099) + id VARCHAR(20) NOT NULL CHECK (id REGEXP '^[0-9]{2}[A-Za-z][0-9]+$'), name VARCHAR(255) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, diff --git a/sysken-pay-backend/docs/openapi.yaml b/sysken-pay-backend/docs/openapi.yaml index 7353b9b..7f72e9a 100644 --- a/sysken-pay-backend/docs/openapi.yaml +++ b/sysken-pay-backend/docs/openapi.yaml @@ -38,7 +38,7 @@ paths: schema: $ref: "#/components/schemas/PostUserRequest" responses: - "200": + "201": description: 登録成功 content: application/json: @@ -50,6 +50,12 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + "409": + description: ユーザーIDが既に登録済み + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" "500": description: サーバーエラー content: @@ -58,6 +64,37 @@ paths: $ref: "#/components/schemas/ErrorResponse" /v1/user/{user_id}: + get: + tags: [user] + summary: ユーザー取得 + operationId: getUser + parameters: + - $ref: "#/components/parameters/UserId" + responses: + "200": + description: 取得成功 + content: + application/json: + schema: + $ref: "#/components/schemas/GetUserResponse" + "400": + description: リクエスト不正 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: ユーザーが存在しない + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: サーバーエラー + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" patch: tags: [user] summary: ユーザー更新 @@ -105,7 +142,7 @@ paths: schema: $ref: "#/components/schemas/PostChargeRequest" responses: - "200": + "201": description: チャージ成功 content: application/json: @@ -172,7 +209,7 @@ paths: schema: $ref: "#/components/schemas/PostPurchaseRequest" responses: - "200": + "201": description: 購入成功 content: application/json: @@ -308,7 +345,7 @@ paths: schema: $ref: "#/components/schemas/PostItemRequest" responses: - "200": + "201": description: 登録成功 content: application/json: @@ -427,10 +464,12 @@ components: name: user_id in: path required: true - description: ユーザーID + description: "ユーザーID(学籍番号フォーマット: 2桁の入学年度数字 + 1文字のアルファベット + 数字の連番)" schema: type: string + pattern: "^[0-9]{2}[A-Za-z][0-9]+$" maxLength: 20 + example: "20K23099" schemas: # ==================== Request Schemas ==================== @@ -440,8 +479,10 @@ components: properties: user_id: type: string + pattern: "^[0-9]{2}[A-Za-z][0-9]+$" maxLength: 20 - description: ユーザーID + example: "20K23099" + description: "ユーザーID(学籍番号フォーマット: 2桁の入学年度数字 + 1文字のアルファベット + 数字の連番)" user_name: type: string minLength: 1 @@ -557,7 +598,22 @@ components: created_at: type: string format: date-time - example: "2025-01-01T00:00:00.000Z" + example: "2025-01-01T09:00:00.000+09:00" + + GetUserResponse: + type: object + properties: + status: + type: string + example: "success" + user_id: + type: string + user_name: + type: string + created_at: + type: string + format: date-time + example: "2025-01-01T09:00:00.000+09:00" PatchUserResponse: type: object @@ -572,7 +628,7 @@ components: created_at: type: string format: date-time - example: "2025-01-01T00:00:00.000Z" + example: "2025-01-01T09:00:00.000+09:00" ChargeResponse: type: object @@ -591,7 +647,7 @@ components: created_at: type: string format: date-time - example: "2025-01-01T00:00:00.000Z" + example: "2025-01-01T09:00:00.000+09:00" ChargeCancelResponse: type: object @@ -610,7 +666,7 @@ components: created_at: type: string format: date-time - example: "2025-01-01T00:00:00.000Z" + example: "2025-01-01T09:00:00.000+09:00" PostItemResponse: type: object @@ -629,11 +685,11 @@ components: created_at: type: string format: date-time - example: "2025-01-01T00:00:00.000Z" + example: "2025-01-01T09:00:00.000+09:00" updated_at: type: string format: date-time - example: "2025-01-01T00:00:00.000Z" + example: "2025-01-01T09:00:00.000+09:00" PatchItemResponse: type: object @@ -652,11 +708,11 @@ components: created_at: type: string format: date-time - example: "2025-01-01T00:00:00.000Z" + example: "2025-01-01T09:00:00.000+09:00" updated_at: type: string format: date-time - example: "2025-01-01T00:00:00.000Z" + example: "2025-01-01T09:00:00.000+09:00" GetItemResponse: type: object @@ -726,7 +782,7 @@ components: created_at: type: string format: date-time - example: "2025-01-01T00:00:00.000Z" + example: "2025-01-01T09:00:00.000+09:00" GetBalanceResponse: type: object @@ -751,7 +807,7 @@ components: purchase_at: type: string format: date-time - example: "2025-01-01T00:00:00.000Z" + example: "2025-01-01T09:00:00.000+09:00" PageInfo: type: object diff --git a/sysken-pay-front/src/adapter/api/ApiError.ts b/sysken-pay-front/src/adapter/api/ApiError.ts new file mode 100644 index 0000000..9744f31 --- /dev/null +++ b/sysken-pay-front/src/adapter/api/ApiError.ts @@ -0,0 +1,9 @@ +export class ApiError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} diff --git a/sysken-pay-front/src/adapter/repository/UserRepositoryImpl.ts b/sysken-pay-front/src/adapter/repository/UserRepositoryImpl.ts index 32459c1..c3e168b 100644 --- a/sysken-pay-front/src/adapter/repository/UserRepositoryImpl.ts +++ b/sysken-pay-front/src/adapter/repository/UserRepositoryImpl.ts @@ -1,12 +1,41 @@ import { apiClient } from "../api/client"; +import { ApiError } from "../api/ApiError"; import type { components } from "../../types/api-schema"; export const UserRepositoryImpl = { + getUser: async ( + userId: string + ): Promise => { + const { data, error, response } = await apiClient.GET( + "/v1/user/{user_id}", + { + params: { path: { user_id: userId } }, + } + ); + if (error) { + throw new ApiError( + error.message ?? "ユーザー取得に失敗しました", + response.status + ); + } + if (!data?.user_id) { + throw new ApiError("ユーザーが見つかりませんでした", response.status); + } + return data; + }, + registerUser: async ( body: components["schemas"]["PostUserRequest"] ): Promise => { - const { data, error } = await apiClient.POST("/v1/user", { body }); - if (error) throw new Error(error.message); + const { data, error, response } = await apiClient.POST("/v1/user", { + body, + }); + if (error) { + throw new ApiError( + error.message ?? "ユーザー登録に失敗しました", + response.status + ); + } return data; }, diff --git a/sysken-pay-front/src/pages/admin/user-register/index.tsx b/sysken-pay-front/src/pages/admin/user-register/index.tsx index f79fedb..d0f5149 100644 --- a/sysken-pay-front/src/pages/admin/user-register/index.tsx +++ b/sysken-pay-front/src/pages/admin/user-register/index.tsx @@ -5,6 +5,7 @@ import { useNavigate } from "react-router-dom"; import Header from "../../../components/layouts/Header"; import { useUserStore } from "../../../store/useUserStore"; import { UserRepositoryImpl } from "../../../adapter/repository/UserRepositoryImpl"; +import { ApiError } from "../../../adapter/api/ApiError"; import ArrowButton from "../../../components/ui/ArrowButton"; import styles from "./index.module.scss"; @@ -22,13 +23,21 @@ export default function UserRegisterPage(): JSX.Element { setError(null); clearScannedUser(); try { - await UserRepositoryImpl.getBalance(barcode); + await UserRepositoryImpl.getUser(barcode); setError("このユーザーはすでに登録済みです"); - } catch { - setScannedUser({ user_id: barcode }); - navigate("/admin/user-register/name"); + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + setScannedUser({ user_id: barcode }); + navigate("/admin/user-register/name"); + return; + } + if (err instanceof ApiError && err.status === 400) { + setError("バーコードの形式が正しくありません"); + return; + } + setError("ユーザー確認に失敗しました"); } - } + }; return (
diff --git a/sysken-pay-front/src/pages/admin/user-register/name.tsx b/sysken-pay-front/src/pages/admin/user-register/name.tsx index 998c93c..7093042 100644 --- a/sysken-pay-front/src/pages/admin/user-register/name.tsx +++ b/sysken-pay-front/src/pages/admin/user-register/name.tsx @@ -8,6 +8,7 @@ import Header from "../../../components/layouts/Header"; import ArrowButton from "../../../components/ui/ArrowButton"; import { useUserStore } from "../../../store/useUserStore"; import { UserRepositoryImpl } from "../../../adapter/repository/UserRepositoryImpl"; +import { ApiError } from "../../../adapter/api/ApiError"; import styles from "./name.module.scss"; export default function UserRegisterNamePage(): JSX.Element { @@ -39,6 +40,10 @@ export default function UserRegisterNamePage(): JSX.Element { setErrorMessage(""); setShowModal(true); } catch (e) { + if (e instanceof ApiError && e.status === 409) { + setErrorMessage("このユーザーIDはすでに登録されています"); + return; + } setErrorMessage(e instanceof Error ? e.message : "登録に失敗しました"); } } diff --git a/sysken-pay-front/src/pages/admin/user-update/index.tsx b/sysken-pay-front/src/pages/admin/user-update/index.tsx index 7b02ade..5a9f570 100644 --- a/sysken-pay-front/src/pages/admin/user-update/index.tsx +++ b/sysken-pay-front/src/pages/admin/user-update/index.tsx @@ -5,6 +5,7 @@ import { useNavigate } from "react-router-dom"; import Header from "../../../components/layouts/Header"; import { useUserStore } from "../../../store/useUserStore"; import { UserRepositoryImpl } from "../../../adapter/repository/UserRepositoryImpl"; +import { ApiError } from "../../../adapter/api/ApiError"; import ArrowButton from "../../../components/ui/ArrowButton"; import styles from "./index.module.scss"; @@ -22,13 +23,21 @@ export default function UserUpdatePage(): JSX.Element { setError(null); clearScannedUser(); try { - await UserRepositoryImpl.getBalance(barcode); + await UserRepositoryImpl.getUser(barcode); setScannedUser({ user_id: barcode }); navigate("/admin/user-update/name"); - } catch { - setError("このユーザーは登録されていません"); + } catch (err) { + if (err instanceof ApiError && err.status === 400) { + setError("バーコードの形式が正しくありません"); + return; + } + if (err instanceof ApiError && err.status === 404) { + setError("このユーザーは登録されていません"); + return; + } + setError("ユーザー確認に失敗しました"); } - } + }; return (
diff --git a/sysken-pay-front/src/test/repository/PurchaseRepositoryImpl.test.ts b/sysken-pay-front/src/test/repository/PurchaseRepositoryImpl.test.ts index 35ed384..3eb086a 100644 --- a/sysken-pay-front/src/test/repository/PurchaseRepositoryImpl.test.ts +++ b/sysken-pay-front/src/test/repository/PurchaseRepositoryImpl.test.ts @@ -39,18 +39,19 @@ describe("PurchaseRepositoryImpl", () => { const response = { status: "success", purchase_id: 1, user_id: "k24000", balance: 1000 }; mockPost.mockResolvedValue({ data: response, error: undefined }); - const result = await PurchaseRepositoryImpl.cancelPurchase("k24000", { purchase_id: 1 }); + const body = { items: [{ item_id: 1, quantity: 1 }] }; + const result = await PurchaseRepositoryImpl.cancelPurchase("k24000", body); expect(result).toEqual(response); expect(mockPost).toHaveBeenCalledWith("/v1/user/{user_id}/purchase/cancel", { params: { path: { user_id: "k24000" } }, - body: { purchase_id: 1 }, + body, }); }); it("エラー時に例外を投げる", async () => { mockPost.mockResolvedValue({ data: undefined, error: { message: "キャンセル失敗" } }); await expect( - PurchaseRepositoryImpl.cancelPurchase("k24000", { purchase_id: 99 }) + PurchaseRepositoryImpl.cancelPurchase("k24000", { items: [{ item_id: 99, quantity: 1 }] }) ).rejects.toThrow("キャンセル失敗"); }); }); diff --git a/sysken-pay-front/src/test/repository/UserRepositoryImpl.test.ts b/sysken-pay-front/src/test/repository/UserRepositoryImpl.test.ts index 80739f8..363b28b 100644 --- a/sysken-pay-front/src/test/repository/UserRepositoryImpl.test.ts +++ b/sysken-pay-front/src/test/repository/UserRepositoryImpl.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { UserRepositoryImpl } from "../../adapter/repository/UserRepositoryImpl"; +import { ApiError } from "../../adapter/api/ApiError"; const mockPost = vi.hoisted(() => vi.fn()); const mockPatch = vi.hoisted(() => vi.fn()); @@ -16,6 +17,41 @@ beforeEach(() => { }); describe("UserRepositoryImpl", () => { + describe("getUser", () => { + it("ユーザー取得が成功する", async () => { + const response = { status: "success", user_id: "k24000", user_name: "シス研太郎" }; + mockGet.mockResolvedValue({ data: response, error: undefined, response: { status: 200 } }); + + const result = await UserRepositoryImpl.getUser("k24000"); + expect(result).toEqual(response); + expect(mockGet).toHaveBeenCalledWith("/v1/user/{user_id}", { + params: { path: { user_id: "k24000" } }, + }); + }); + + it("404 のとき status=404 の ApiError を投げる", async () => { + mockGet.mockResolvedValue({ + data: undefined, + error: { message: "user not found" }, + response: { status: 404 }, + }); + const promise = UserRepositoryImpl.getUser("99X99999"); + await expect(promise).rejects.toBeInstanceOf(ApiError); + await expect(promise).rejects.toMatchObject({ status: 404, message: "user not found" }); + }); + + it("400 のとき status=400 の ApiError を投げる", async () => { + mockGet.mockResolvedValue({ + data: undefined, + error: { message: "invalid user_id" }, + response: { status: 400 }, + }); + const promise = UserRepositoryImpl.getUser("invalid"); + await expect(promise).rejects.toBeInstanceOf(ApiError); + await expect(promise).rejects.toMatchObject({ status: 400, message: "invalid user_id" }); + }); + }); + describe("registerUser", () => { it("ユーザー登録が成功する", async () => { const response = { status: "success", user_id: "k24000", user_name: "シス研太郎" }; @@ -28,8 +64,23 @@ describe("UserRepositoryImpl", () => { }); }); - it("エラー時に例外を投げる", async () => { - mockPost.mockResolvedValue({ data: undefined, error: { message: "登録失敗" } }); + it("409 のとき status=409 の ApiError を投げる", async () => { + mockPost.mockResolvedValue({ + data: undefined, + error: { message: "user already exists" }, + response: { status: 409 }, + }); + const promise = UserRepositoryImpl.registerUser({ user_id: "20K24042", user_name: "シス研太郎" }); + await expect(promise).rejects.toBeInstanceOf(ApiError); + await expect(promise).rejects.toMatchObject({ status: 409, message: "user already exists" }); + }); + + it("エラー時に ApiError を投げる", async () => { + mockPost.mockResolvedValue({ + data: undefined, + error: { message: "登録失敗" }, + response: { status: 500 }, + }); await expect( UserRepositoryImpl.registerUser({ user_id: "k24000", user_name: "シス研太郎" }) ).rejects.toThrow("登録失敗"); diff --git a/sysken-pay-front/src/types/api-schema.d.ts b/sysken-pay-front/src/types/api-schema.d.ts index 3cfb5f0..4aba44e 100644 --- a/sysken-pay-front/src/types/api-schema.d.ts +++ b/sysken-pay-front/src/types/api-schema.d.ts @@ -28,7 +28,8 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + /** ユーザー取得 */ + get: operations["getUser"]; put?: never; post?: never; delete?: never; @@ -198,7 +199,10 @@ export type webhooks = Record; export interface components { schemas: { PostUserRequest: { - /** @description ユーザーID */ + /** + * @description ユーザーID(学籍番号フォーマット: 2桁の入学年度数字 + 1文字のアルファベット + 数字の連番) + * @example 20K23099 + */ user_id: string; /** @description ユーザー名 */ user_name: string; @@ -256,7 +260,18 @@ export interface components { user_name?: string; /** * Format: date-time - * @example 2025-01-01T00:00:00.000Z + * @example 2025-01-01T09:00:00.000+09:00 + */ + created_at?: string; + }; + GetUserResponse: { + /** @example success */ + status?: string; + user_id?: string; + user_name?: string; + /** + * Format: date-time + * @example 2025-01-01T09:00:00.000+09:00 */ created_at?: string; }; @@ -267,7 +282,7 @@ export interface components { user_name?: string; /** * Format: date-time - * @example 2025-01-01T00:00:00.000Z + * @example 2025-01-01T09:00:00.000+09:00 */ created_at?: string; }; @@ -280,7 +295,7 @@ export interface components { balance?: number; /** * Format: date-time - * @example 2025-01-01T00:00:00.000Z + * @example 2025-01-01T09:00:00.000+09:00 */ created_at?: string; }; @@ -293,7 +308,7 @@ export interface components { balance?: number; /** * Format: date-time - * @example 2025-01-01T00:00:00.000Z + * @example 2025-01-01T09:00:00.000+09:00 */ created_at?: string; }; @@ -306,12 +321,12 @@ export interface components { price?: number; /** * Format: date-time - * @example 2025-01-01T00:00:00.000Z + * @example 2025-01-01T09:00:00.000+09:00 */ created_at?: string; /** * Format: date-time - * @example 2025-01-01T00:00:00.000Z + * @example 2025-01-01T09:00:00.000+09:00 */ updated_at?: string; }; @@ -324,12 +339,12 @@ export interface components { price?: number; /** * Format: date-time - * @example 2025-01-01T00:00:00.000Z + * @example 2025-01-01T09:00:00.000+09:00 */ created_at?: string; /** * Format: date-time - * @example 2025-01-01T00:00:00.000Z + * @example 2025-01-01T09:00:00.000+09:00 */ updated_at?: string; }; @@ -367,7 +382,7 @@ export interface components { items?: components["schemas"]["PurchaseItem"][]; /** * Format: date-time - * @example 2025-01-01T00:00:00.000Z + * @example 2025-01-01T09:00:00.000+09:00 */ created_at?: string; }; @@ -383,7 +398,7 @@ export interface components { price?: number; /** * Format: date-time - * @example 2025-01-01T00:00:00.000Z + * @example 2025-01-01T09:00:00.000+09:00 */ purchase_at?: string; }; @@ -412,7 +427,7 @@ export interface components { }; responses: never; parameters: { - /** @description ユーザーID */ + /** @description ユーザーID(学籍番号フォーマット: 2桁の入学年度数字 + 1文字のアルファベット + 数字の連番) */ UserId: string; }; requestBodies: never; @@ -435,7 +450,7 @@ export interface operations { }; responses: { /** @description 登録成功 */ - 200: { + 201: { headers: { [name: string]: unknown; }; @@ -452,6 +467,65 @@ export interface operations { "application/json": components["schemas"]["ErrorResponse"]; }; }; + /** @description ユーザーIDが既に登録済み */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description サーバーエラー */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getUser: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ユーザーID(学籍番号フォーマット: 2桁の入学年度数字 + 1文字のアルファベット + 数字の連番) */ + user_id: components["parameters"]["UserId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 取得成功 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetUserResponse"]; + }; + }; + /** @description リクエスト不正 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description ユーザーが存在しない */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; /** @description サーバーエラー */ 500: { headers: { @@ -468,7 +542,7 @@ export interface operations { query?: never; header?: never; path: { - /** @description ユーザーID */ + /** @description ユーザーID(学籍番号フォーマット: 2桁の入学年度数字 + 1文字のアルファベット + 数字の連番) */ user_id: components["parameters"]["UserId"]; }; cookie?: never; @@ -513,7 +587,7 @@ export interface operations { query?: never; header?: never; path: { - /** @description ユーザーID */ + /** @description ユーザーID(学籍番号フォーマット: 2桁の入学年度数字 + 1文字のアルファベット + 数字の連番) */ user_id: components["parameters"]["UserId"]; }; cookie?: never; @@ -525,7 +599,7 @@ export interface operations { }; responses: { /** @description チャージ成功 */ - 200: { + 201: { headers: { [name: string]: unknown; }; @@ -558,7 +632,7 @@ export interface operations { query?: never; header?: never; path: { - /** @description ユーザーID */ + /** @description ユーザーID(学籍番号フォーマット: 2桁の入学年度数字 + 1文字のアルファベット + 数字の連番) */ user_id: components["parameters"]["UserId"]; }; cookie?: never; @@ -603,7 +677,7 @@ export interface operations { query?: never; header?: never; path: { - /** @description ユーザーID */ + /** @description ユーザーID(学籍番号フォーマット: 2桁の入学年度数字 + 1文字のアルファベット + 数字の連番) */ user_id: components["parameters"]["UserId"]; }; cookie?: never; @@ -615,7 +689,7 @@ export interface operations { }; responses: { /** @description 購入成功 */ - 200: { + 201: { headers: { [name: string]: unknown; }; @@ -648,7 +722,7 @@ export interface operations { query?: never; header?: never; path: { - /** @description ユーザーID */ + /** @description ユーザーID(学籍番号フォーマット: 2桁の入学年度数字 + 1文字のアルファベット + 数字の連番) */ user_id: components["parameters"]["UserId"]; }; cookie?: never; @@ -693,7 +767,7 @@ export interface operations { query?: never; header?: never; path: { - /** @description ユーザーID */ + /** @description ユーザーID(学籍番号フォーマット: 2桁の入学年度数字 + 1文字のアルファベット + 数字の連番) */ user_id: components["parameters"]["UserId"]; }; cookie?: never; @@ -739,7 +813,7 @@ export interface operations { }; header?: never; path: { - /** @description ユーザーID */ + /** @description ユーザーID(学籍番号フォーマット: 2桁の入学年度数字 + 1文字のアルファベット + 数字の連番) */ user_id: components["parameters"]["UserId"]; }; cookie?: never; @@ -818,7 +892,7 @@ export interface operations { }; responses: { /** @description 登録成功 */ - 200: { + 201: { headers: { [name: string]: unknown; };