package handler import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os" "testing" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" "apskel-pos-be/internal/appcontext" applogger "apskel-pos-be/internal/logger" "apskel-pos-be/internal/middleware" "apskel-pos-be/internal/processor" "apskel-pos-be/internal/repository" "apskel-pos-be/internal/service" ) // Drives GET/PUT /outlets/:id/loyalty-settings over HTTP down to Postgres. Needs // TEST_DATABASE_URL pointing at a migrated database; see // internal/repository/wallet_repository_test.go. func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) { dsn := os.Getenv("TEST_DATABASE_URL") if dsn == "" { t.Skip("TEST_DATABASE_URL not set") } applogger.Setup("fatal", "json") db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) require.NoError(t, err) org, otherOrg, outlet, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New() exec := func(q string, args ...any) { t.Helper() require.NoError(t, db.Exec(q, args...).Error) } exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'A', 'basic'), (?, 'B', 'basic')`, org, otherOrg) exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org) t.Cleanup(func() { db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg}) db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet) db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org) db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet) db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg}) }) settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db)) h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings, repository.NewWalletQueryRepository(db))) auth := middleware.NewAuthMiddleware(nil) gin.SetMode(gin.TestMode) router := gin.New() as := func(orgID uuid.UUID, role string) gin.HandlerFunc { return func(c *gin.Context) { ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, orgID.String()) ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String()) ctx = context.WithValue(ctx, appcontext.UserRoleKey, role) c.Request = c.Request.WithContext(ctx) } } for prefix, who := range map[string]struct { org uuid.UUID role string }{"/manager": {org, "manager"}, "/purchasing": {org, "purchasing"}, "/other": {otherOrg, "admin"}} { g := router.Group(prefix, as(who.org, who.role)) g.GET("/outlets/:outlet_id/loyalty-settings", h.GetOutletSettings) g.PUT("/outlets/:outlet_id/loyalty-settings", auth.RequireLoyaltyManager(), h.UpdateOutletSettings) } call := func(method, path string, body string) (int, map[string]any) { t.Helper() req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) var out map[string]any require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String()) return rec.Code, out } path := "/outlets/" + outlet.String() + "/loyalty-settings" data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) } // An outlet without settings shows every default and 1% cashback. status, body := call(http.MethodGet, "/manager"+path, "") require.Equal(t, http.StatusOK, status, body) got := data(body) assert.Equal(t, map[string]any{"enabled": false, "earn_mode": "PER_AMOUNT", "earn_per_amount": float64(100), "earn_value": float64(1), "earn_percent": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"]) assert.Equal(t, map[string]any{"enabled": false, "earn_mode": "PER_AMOUNT", "earn_per_amount": float64(25000), "earn_value": float64(1), "earn_percent": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"]) assert.NotContains(t, got, "point_payment", "EnakPoint cannot pay (docs/enakgame-prd.md ยง3.2)") assert.EqualValues(t, 1, got["point_value"]) assert.EqualValues(t, 1, got["point_cashback_percent"]) // A partial update keeps everything it does not mention. status, body = call(http.MethodPut, "/manager"+path, `{"point": {"enabled": true, "earn_per_amount": 1000}, "coin": {"max_per_order": 3}}`) require.Equal(t, http.StatusOK, status, body) got = data(body) assert.Equal(t, true, got["point"].(map[string]any)["enabled"]) assert.EqualValues(t, 1000, got["point"].(map[string]any)["earn_per_amount"]) assert.EqualValues(t, 1, got["point"].(map[string]any)["earn_value"], "untouched") assert.EqualValues(t, 25000, got["coin"].(map[string]any)["earn_per_amount"], "untouched") assert.EqualValues(t, 3, got["coin"].(map[string]any)["max_per_order"]) assert.EqualValues(t, 0.1, got["point_cashback_percent"], "1 point per Rp 1.000 at Rp 1 a point") assert.Len(t, got["changes"], 3) // The cashback follows the organization's point value. orgSettings, err := settings.Organization(context.Background(), org) require.NoError(t, err) orgSettings.PointValue = 5 _, _, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings) require.NoError(t, err) status, body = call(http.MethodGet, "/manager"+path, "") require.Equal(t, http.StatusOK, status, body) assert.EqualValues(t, 0.5, data(body)["point_cashback_percent"]) // Sending back what GET returned changes nothing. echo, err := json.Marshal(data(body)) require.NoError(t, err) status, body = call(http.MethodPut, "/manager"+path, string(echo)) require.Equal(t, http.StatusOK, status, body) assert.Empty(t, data(body)["changes"]) // null clears a limit. status, body = call(http.MethodPut, "/manager"+path, `{"coin": {"max_per_order": null}}`) require.Equal(t, http.StatusOK, status, body) assert.Nil(t, data(body)["coin"].(map[string]any)["max_per_order"]) // Values out of bounds, unknown fields and bad JSON are refused and change nothing. for name, bad := range map[string]string{ "earn_per_amount 0": `{"point": {"earn_per_amount": 0}}`, "negative earn_value": `{"coin": {"earn_value": -1}}`, "earn mode unknown": `{"point": {"earn_mode": "PERCENT"}}`, "earn_percent over 100": `{"coin": {"earn_percent": 101}}`, "negative min_order": `{"point": {"min_order_amount": -5}}`, "negative max_per_order": `{"point": {"max_per_order": -1}}`, "point_payment removed": `{"point_payment": {"accept_payment": true}}`, "unknown field": `{"point": {"earn_per_amout": 50}}`, "wrong type": `{"point": {"enabled": "yes"}}`, "not json": `enabled=true`, } { status, _ = call(http.MethodPut, "/manager"+path, bad) assert.Equal(t, http.StatusBadRequest, status, name) } status, body = call(http.MethodGet, "/manager"+path, "") require.Equal(t, http.StatusOK, status, body) assert.EqualValues(t, 1000, data(body)["point"].(map[string]any)["earn_per_amount"]) // Purchasing staff can read but not change; another organization sees nothing. status, _ = call(http.MethodPut, "/purchasing"+path, `{"point": {"enabled": false}}`) assert.Equal(t, http.StatusForbidden, status) status, _ = call(http.MethodGet, "/other"+path, "") assert.Equal(t, http.StatusNotFound, status) status, _ = call(http.MethodPut, "/other"+path, `{"point": {"enabled": false}}`) assert.Equal(t, http.StatusNotFound, status) status, _ = call(http.MethodGet, "/manager/outlets/not-a-uuid/loyalty-settings", "") assert.Equal(t, http.StatusBadRequest, status) // Every change is in the history with who made it. history, err := settings.ListChanges(context.Background(), org, &outlet, 1, 100) require.NoError(t, err) assert.EqualValues(t, 4, history.Pagination.Total, "three keys, then the limit cleared") for _, c := range history.Data { assert.Equal(t, admin, c.ChangedBy) } }