diff --git a/internal/context/context.go b/internal/context/context.go index a30a7a3..3d3b928 100644 --- a/internal/context/context.go +++ b/internal/context/context.go @@ -34,6 +34,9 @@ type NFContext struct { Messages []Message DragonBallData map[string]int32 + + Fortunes []string + FortuneMutex sync.RWMutex } type Message struct { @@ -97,6 +100,16 @@ func InitNfContext() { "Krillin": 2, "Yamcha": 1, } + + nfContext.Fortunes = []string{ + "大吉: All your endeavors will be successful.", + "中吉: You will have good luck, but be cautious.", + "小吉: A small amount of luck is coming your way.", + "吉: Good fortune is with you.", + "末吉: Your luck is gradually improving.", + "凶: Be careful, misfortune may be ahead.", + "大凶: A great misfortune is coming. Be prepared.", + } } func GetSelf() *NFContext { diff --git a/internal/sbi/api_foodpicker.go b/internal/sbi/api_foodpicker.go deleted file mode 100644 index 39c5cd5..0000000 --- a/internal/sbi/api_foodpicker.go +++ /dev/null @@ -1,60 +0,0 @@ -package sbi - -import ( - "math/rand" - "net/http" - "sync" - - "github.com/gin-gonic/gin" -) - -var ( - foodList = []string{"Beef Noodle", "Burger", "Bento", "McDonalds", "Luway"} - mu sync.Mutex // To ensure thread-safe operations on the food list -) - -func (s *Server) getFoodPickerRoutes() []Route { - return []Route{ - { - Name: "FoodPicker GET", - Method: http.MethodGet, - Pattern: "", - APIFunc: func(c *gin.Context) { - mu.Lock() - defer mu.Unlock() - - randomFood := foodList[rand.Intn(len(foodList))] - c.JSON(http.StatusOK, gin.H{"lunch/dinner pick": randomFood}) - }, - // curl -X GET http://127.0.0.163:8000/foodpicker -w "\n" - }, - { - Name: "FoodPicker POST", - Method: http.MethodPost, - Pattern: "", - APIFunc: func(c *gin.Context) { - var newFood struct { - Name string `json:"name"` - } - if err := c.ShouldBindJSON(&newFood); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - mu.Lock() - defer mu.Unlock() - - foodList = append(foodList, newFood.Name) - c.JSON(http.StatusOK, gin.H{"message": "Food added successfully", "foodList": foodList}) - }, - // curl -X POST http://127.0.0.163:8000/foodpicker \ - // -H "Content-Type: application/json" \ - // -d '{"name":"McDonalds"}' -w "\n" - }, - } -} - -// foodpicker.go -func (s *Server) GetFoodPickerRoutes() []Route { - return s.getFoodPickerRoutes() -} diff --git a/internal/sbi/api_foodpicker_test.go b/internal/sbi/api_foodpicker_test.go deleted file mode 100644 index be0a79c..0000000 --- a/internal/sbi/api_foodpicker_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package sbi_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/Alonza0314/nf-example/internal/sbi" - "github.com/Alonza0314/nf-example/pkg/factory" - "github.com/gin-gonic/gin" - "go.uber.org/mock/gomock" -) - -func setupFoodPickerServer(t *testing.T) *sbi.Server { - t.Helper() - gin.SetMode(gin.TestMode) - - mockCtrl := gomock.NewController(t) - nfApp := sbi.NewMocknfApp(mockCtrl) - nfApp.EXPECT().Config().Return(&factory.Config{ - Configuration: &factory.Configuration{ - Sbi: &factory.Sbi{ - Port: 8000, - }, - }, - }).AnyTimes() - - return sbi.NewServer(nfApp, "") -} - -func Test_FoodPickerGET(t *testing.T) { - server := setupFoodPickerServer(t) - - httpRecorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(httpRecorder) - - req, err := http.NewRequest(http.MethodGet, "/foodpicker", nil) - if err != nil { - t.Fatalf("Failed to create GET request: %v", err) - } - ginCtx.Request = req - - // Call the GET handler directly - for _, route := range server.GetFoodPickerRoutes() { - if route.Method == http.MethodGet { - route.APIFunc(ginCtx) - } - } - - if httpRecorder.Code != http.StatusOK { - t.Errorf("Expected status 200 OK, got %d", httpRecorder.Code) - } - - var body map[string]string - if err = json.Unmarshal(httpRecorder.Body.Bytes(), &body); err != nil { - t.Errorf("Failed to parse response JSON: %v", err) - } - - if _, ok := body["lunch/dinner pick"]; !ok { - t.Errorf("Expected key 'lunch/dinner pick' in response body") - } -} - -func Test_FoodPickerPOST(t *testing.T) { - server := setupFoodPickerServer(t) - - httpRecorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(httpRecorder) - - payload := map[string]string{"name": "Sushi"} - jsonData, err := json.Marshal(payload) - if err != nil { - t.Fatalf("Failed to marshal payload: %v", err) - } - - req, err := http.NewRequest(http.MethodPost, "/foodpicker", bytes.NewBuffer(jsonData)) - if err != nil { - t.Fatalf("Failed to create POST request: %v", err) - } - req.Header.Set("Content-Type", "application/json") - ginCtx.Request = req - - // Call the POST handler directly - for _, route := range server.GetFoodPickerRoutes() { - if route.Method == http.MethodPost { - route.APIFunc(ginCtx) - } - } - - if httpRecorder.Code != http.StatusOK { - t.Errorf("Expected status 200 OK, got %d", httpRecorder.Code) - } - - var resp map[string]interface{} - if err = json.Unmarshal(httpRecorder.Body.Bytes(), &resp); err != nil { - t.Errorf("Failed to parse response JSON: %v", err) - } - - if resp["message"] != "Food added successfully" { - t.Errorf("Expected success message, got %v", resp["message"]) - } - - // Optional: Check if "Sushi" is present in returned list - list, ok := resp["foodList"].([]interface{}) - if !ok { - t.Errorf("Expected foodList array in response") - } else { - found := false - for _, f := range list { - if f == "Sushi" { - found = true - break - } - } - if !found { - t.Errorf("Expected 'Sushi' to be in foodList") - } - } -} diff --git a/internal/sbi/api_fortune.go b/internal/sbi/api_fortune.go new file mode 100644 index 0000000..fd47a3b --- /dev/null +++ b/internal/sbi/api_fortune.go @@ -0,0 +1,58 @@ +package sbi + +import ( + "net/http" + + "github.com/Alonza0314/nf-example/internal/logger" + "github.com/Alonza0314/nf-example/internal/sbi/processor" + "github.com/gin-gonic/gin" +) + +func (s *Server) getFortuneRoute() []Route { + return []Route{ + { + Name: "Get Today's Fortune", + Method: http.MethodGet, + Pattern: "/", + APIFunc: s.HTTPGetFortune, + // Use + // curl -X GET http://127.0.0.163:8000/fortune/ -w "\n" + }, + { + Name: "Add a new Fortune", + Method: http.MethodPost, + Pattern: "/", + APIFunc: s.HTTPPostFortune, + // Use + // curl -X POST http://127.0.0.163:8000/fortune/ \ + // -H "Content-Type: application/json" \ + // -d '{"fortune":"New fortune text"}' -w "\n" + }, + } +} + +func (s *Server) HTTPGetFortune(c *gin.Context) { + logger.SBILog.Infof("In HTTPGetFortune") + + s.Processor().GetFortune(c) +} + +func (s *Server) HTTPPostFortune(c *gin.Context) { + logger.SBILog.Infof("In HTTPPostFortune") + + var req processor.PostFortuneRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.SBILog.Errorf("Invalid request body: %+v", err) + c.JSON(http.StatusBadRequest, gin.H{ + "message": "Invalid request body", + "error": err.Error(), + }) + return + } + + s.Processor().PostFortune(c, req) +} + +func (s *Server) GetFortuneRoute() []Route { + return s.getFortuneRoute() +} diff --git a/internal/sbi/api_fortune_test.go b/internal/sbi/api_fortune_test.go new file mode 100644 index 0000000..99688f5 --- /dev/null +++ b/internal/sbi/api_fortune_test.go @@ -0,0 +1,65 @@ +package sbi_test + +import ( + "net/http" + "testing" + + "github.com/Alonza0314/nf-example/internal/sbi" + "github.com/Alonza0314/nf-example/pkg/factory" + "go.uber.org/mock/gomock" +) + +func Test_GetFortuneRoute(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + nfApp := sbi.NewMocknfApp(mockCtrl) + nfApp.EXPECT().Config().Return(&factory.Config{ + Configuration: &factory.Configuration{ + Sbi: &factory.Sbi{ + Port: 8000, + }, + }, + }).AnyTimes() + + server := sbi.NewServer(nfApp, "") + + routes := server.GetFortuneRoute() + if routes == nil { + t.Fatalf("Expected routes slice, got nil") + } + + if len(routes) != 2 { + t.Fatalf("Expected 2 routes, got %d", len(routes)) + } + + // Validate first route (GET) + r0 := routes[0] + if r0.Name != "Get Today's Fortune" { + t.Errorf("Route 0 Name: expected %q, got %q", "Get Today's Fortune", r0.Name) + } + if r0.Method != http.MethodGet { + t.Errorf("Route 0 Method: expected %q, got %q", http.MethodGet, r0.Method) + } + if r0.Pattern != "/" { + t.Errorf("Route 0 Pattern: expected %q, got %q", "/", r0.Pattern) + } + if r0.APIFunc == nil { + t.Errorf("Route 0 APIFunc should not be nil") + } + + // Validate second route (POST) + r1 := routes[1] + if r1.Name != "Add a new Fortune" { + t.Errorf("Route 1 Name: expected %q, got %q", "Add a new Fortune", r1.Name) + } + if r1.Method != http.MethodPost { + t.Errorf("Route 1 Method: expected %q, got %q", http.MethodPost, r1.Method) + } + if r1.Pattern != "/" { + t.Errorf("Route 1 Pattern: expected %q, got %q", "/", r1.Pattern) + } + if r1.APIFunc == nil { + t.Errorf("Route 1 APIFunc should not be nil") + } +} diff --git a/internal/sbi/processor/fortune.go b/internal/sbi/processor/fortune.go new file mode 100644 index 0000000..16557ef --- /dev/null +++ b/internal/sbi/processor/fortune.go @@ -0,0 +1,51 @@ +package processor + +import ( + "math/rand" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +type PostFortuneRequest struct { + Fortune string `json:"fortune" binding:"required"` +} + +func (p *Processor) GetFortune(c *gin.Context) { + ctx := p.Context() + + ctx.FortuneMutex.RLock() + defer ctx.FortuneMutex.RUnlock() + + if len(ctx.Fortunes) == 0 { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "No fortunes available.", + }) + return + } + + // Seed the random number generator + rand.New(rand.NewSource(time.Now().UnixNano())) + // Get a random fortune + fortune := ctx.Fortunes[rand.Intn(len(ctx.Fortunes))] + + c.JSON(http.StatusOK, gin.H{ + "message": "Here is your fortune for today!", + "fortune": fortune, + }) +} + +func (p *Processor) PostFortune(c *gin.Context, req PostFortuneRequest) { + ctx := p.Context() + + ctx.FortuneMutex.Lock() + defer ctx.FortuneMutex.Unlock() + + ctx.Fortunes = append(ctx.Fortunes, req.Fortune) + + c.JSON(http.StatusCreated, gin.H{ + "message": "Fortune added successfully", + "fortune": req.Fortune, + }) +} diff --git a/internal/sbi/processor/fortune_test.go b/internal/sbi/processor/fortune_test.go new file mode 100644 index 0000000..7b30023 --- /dev/null +++ b/internal/sbi/processor/fortune_test.go @@ -0,0 +1,116 @@ +package processor_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + nf_context "github.com/Alonza0314/nf-example/internal/context" + "github.com/Alonza0314/nf-example/internal/sbi/processor" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + gomock "go.uber.org/mock/gomock" +) + +func Test_PostFortune_AddsFortune(t *testing.T) { + gin.SetMode(gin.TestMode) + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockNf := processor.NewMockProcessorNf(mockCtrl) + p, err := processor.NewProcessor(mockNf) + assert.NoError(t, err) + + mockCtx := &nf_context.NFContext{ + Fortunes: []string{}, + } + mockNf.EXPECT().Context().Return(mockCtx).Times(1) + + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + + req := processor.PostFortuneRequest{Fortune: "Lucky day"} + p.PostFortune(ginCtx, req) + + assert.Equal(t, http.StatusCreated, rec.Code) + + var resp map[string]interface{} + err = json.Unmarshal(rec.Body.Bytes(), &resp) + assert.NoError(t, err) + + assert.Equal(t, "Fortune added successfully", resp["message"]) + assert.Equal(t, "Lucky day", resp["fortune"]) + assert.Len(t, mockCtx.Fortunes, 1) + assert.Equal(t, "Lucky day", mockCtx.Fortunes[0]) +} + +func Test_GetFortune_ReturnsOneOfFortunes(t *testing.T) { + gin.SetMode(gin.TestMode) + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockNf := processor.NewMockProcessorNf(mockCtrl) + p, err := processor.NewProcessor(mockNf) + assert.NoError(t, err) + + fortunes := []string{"f1", "f2", "f3"} + mockCtx := &nf_context.NFContext{ + Fortunes: fortunes, + } + mockNf.EXPECT().Context().Return(mockCtx).Times(1) + + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + + p.GetFortune(ginCtx) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp map[string]interface{} + err = json.Unmarshal(rec.Body.Bytes(), &resp) + assert.NoError(t, err) + + f, ok := resp["fortune"].(string) + assert.True(t, ok, "fortune should be a string") + // check fortune is one of the provided values + found := false + for _, v := range fortunes { + if v == f { + found = true + break + } + } + assert.True(t, found, "returned fortune must be one of the provided fortunes") + assert.Equal(t, "Here is your fortune for today!", resp["message"]) +} + +func Test_GetFortune_NoFortunes_ReturnsError(t *testing.T) { + gin.SetMode(gin.TestMode) + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockNf := processor.NewMockProcessorNf(mockCtrl) + p, err := processor.NewProcessor(mockNf) + assert.NoError(t, err) + + mockCtx := &nf_context.NFContext{ + Fortunes: []string{}, + } + mockNf.EXPECT().Context().Return(mockCtx).Times(1) + + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + + p.GetFortune(ginCtx) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + + var resp map[string]interface{} + err = json.Unmarshal(rec.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.Equal(t, "No fortunes available.", resp["message"]) +} diff --git a/internal/sbi/router.go b/internal/sbi/router.go index 83a9b7b..6631416 100644 --- a/internal/sbi/router.go +++ b/internal/sbi/router.go @@ -49,9 +49,6 @@ func newRouter(s *Server) *gin.Engine { spyFamilyGroup := router.Group("/spyfamily") applyRoutes(spyFamilyGroup, s.getSpyFamilyRoute()) - foodPickerGroup := router.Group("/foodpicker") // lab6 - applyRoutes(foodPickerGroup, s.getFoodPickerRoutes()) - taskGroup := router.Group("/task") applyRoutes(taskGroup, s.getTaskRoute()) @@ -61,6 +58,9 @@ func newRouter(s *Server) *gin.Engine { dragonBallGroup := router.Group("/dragonball") applyRoutes(dragonBallGroup, s.getDragonBallRoute()) + fortuneGroup := router.Group("/fortune") + applyRoutes(fortuneGroup, s.getFortuneRoute()) + return router }