Files
apskel-pos-backend/internal/handler/loyalty_settings_db_test.go
T
efrilmandClaude Opus 5.5 fe2f459b03 feat(loyalty): organization loyalty settings API
Adds GET and PUT /marketing/loyalty-settings and GET
/marketing/loyalty-settings/history (docs/prd-point-coin.md F2, PC-302).

The settings are the point value, the exchange rate, transfer limits and
the stored expiry settings. PUT merges the body like the outlet settings
and is limited to loyalty managers. Every response carries the impact of
the change on the balances in circulation: outstanding EnakPoint and
EnakCoin, their rupiah value, and the coins exchanged into points, before
and after. With ?dry_run=true nothing is saved and the response lists the
keys that would change, for the warning shown before saving.

Saving records each change in loyalty_setting_changes with who made it;
history can be filtered to one outlet. Changing the value leaves what was
already written alone. The diff behind saving and previewing is shared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:26:16 +07:00

170 lines
7.8 KiB
Go

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_per_amount": float64(100), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"])
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(25000), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"])
assert.Equal(t, map[string]any{"accept_payment": false, "min_payment_points": float64(1), "max_payment_percent": float64(100)}, got["point_payment"])
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}}`,
"negative min_order": `{"point": {"min_order_amount": -5}}`,
"negative max_per_order": `{"point": {"max_per_order": -1}}`,
"payment percent over 100": `{"point_payment": {"max_payment_percent": 101}}`,
"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)
}
}