2026-09-30 15:31:44 +07:00
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 /marketing/loyalty-settings and its history over HTTP down to
// Postgres. Needs TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go.
func TestOrganizationLoyaltySettingsEndpoints_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 , admin , customer := 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')` , org )
exec ( `INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')` , customer , org )
t . Cleanup ( func () {
db . Exec ( `DELETE FROM wallet_lots WHERE customer_id = ?` , customer )
db . Exec ( `DELETE FROM wallet_transactions WHERE customer_id = ?` , customer )
db . Exec ( `DELETE FROM customer_wallets WHERE customer_id = ?` , customer )
db . Exec ( `DELETE FROM loyalty_setting_changes WHERE organization_id = ?` , org )
db . Exec ( `DELETE FROM organization_settings WHERE organization_id = ?` , org )
db . Exec ( `DELETE FROM customers WHERE id = ?` , customer )
db . Exec ( `DELETE FROM organizations WHERE id = ?` , org )
})
// Balances in circulation: 50.000 EnakPoint and 105 EnakCoin.
txm := repository . NewTxManager ( db )
wallet := processor . NewWalletProcessor ( repository . NewWalletRepository ( db ))
for _ , c := range [] struct {
currency , ref string
amount int64
}{{ "POINT" , "LEGACY_POINTS" , 50000 }, { "COIN" , "LEGACY_TOKENS" , 105 }} {
require . NoError ( t , txm . WithTransaction ( context . Background (), func ( ctx context . Context ) error {
_ , err := wallet . Credit ( ctx , processor . WalletCreditInput { WalletEntry : processor . WalletEntry {
CustomerID : customer , Currency : c . currency , Type : "MIGRATION" , Amount : c . amount ,
ReferenceType : c . ref , ReferenceID : uuid . New (), Description : "Saldo awal" ,
Metadata : map [ string ] interface {}{ "frozen" : true }}})
return err
}))
}
settings := processor . NewLoyaltySettingsProcessor ( repository . NewLoyaltySettingsRepository ( db ), txm )
h := NewLoyaltySettingsHandler ( service . NewLoyaltySettingsService ( settings , repository . NewWalletQueryRepository ( db )))
auth := middleware . NewAuthMiddleware ( nil )
gin . SetMode ( gin . TestMode )
router := gin . New ()
for prefix , role := range map [ string ] string { "/manager" : "manager" , "/purchasing" : "purchasing" } {
role := role
g := router . Group ( prefix , func ( c * gin . Context ) {
ctx := context . WithValue ( c . Request . Context (), appcontext . OrganizationIDKey , org . String ())
ctx = context . WithValue ( ctx , appcontext . UserIDKey , admin . String ())
ctx = context . WithValue ( ctx , appcontext . UserRoleKey , role )
c . Request = c . Request . WithContext ( ctx )
})
g . GET ( "/loyalty-settings" , h . GetOrganizationSettings )
g . PUT ( "/loyalty-settings" , auth . RequireLoyaltyManager (), h . UpdateOrganizationSettings )
g . GET ( "/loyalty-settings/history" , h . ListHistory )
}
call := func ( method , path , body string ) ( int , map [ string ] any ) {
t . Helper ()
req := httptest . NewRequest ( method , path , bytes . NewBufferString ( body ))
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
}
data := func ( body map [ string ] any ) map [ string ] any { return body [ "data" ].( map [ string ] any ) }
status , body := call ( http . MethodGet , "/manager/loyalty-settings" , "" )
require . Equal ( t , http . StatusOK , status , body )
got := data ( body )
assert . EqualValues ( t , 1 , got [ "point_value" ])
assert . Equal ( t , map [ string ] any { "coin_amount" : float64 ( 1 ), "point_amount" : float64 ( 1 )}, got [ "exchange" ])
assert . Equal ( t , true , got [ "transfer" ].( map [ string ] any )[ "enabled" ])
assert . Equal ( t , "MONTH" , got [ "point_expiry" ].( map [ string ] any )[ "unit" ])
2026-10-07 20:53:14 +07:00
assert . Equal ( t , map [ string ] any { "user_daily_limit" : float64 ( 0 ), "global_daily_limit" : float64 ( 0 )}, got [ "enakgame" ], "0 is no limit" )
2026-09-30 15:31:44 +07:00
impact := got [ "impact" ].( map [ string ] any )
assert . EqualValues ( t , 50000 , impact [ "outstanding_points" ])
assert . EqualValues ( t , impact [ "point_rupiah_before" ], impact [ "point_rupiah_after" ])
// A dry run shows what the balances in circulation would be worth, and saves nothing.
change := `{"point_value": 100, "exchange": {"coin_amount": 10, "point_amount": 1}}`
status , body = call ( http . MethodPut , "/manager/loyalty-settings?dry_run=true" , change )
require . Equal ( t , http . StatusOK , status , body )
got = data ( body )
assert . Equal ( t , true , got [ "dry_run" ])
impact = got [ "impact" ].( map [ string ] any )
assert . EqualValues ( t , 50000 , impact [ "point_rupiah_before" ])
assert . EqualValues ( t , 5000000 , impact [ "point_rupiah_after" ])
assert . EqualValues ( t , 105 , impact [ "coins_as_points_before" ])
assert . EqualValues ( t , 10 , impact [ "coins_as_points_after" ])
assert . Len ( t , got [ "changes" ], 2 )
status , body = call ( http . MethodGet , "/manager/loyalty-settings" , "" )
require . Equal ( t , http . StatusOK , status , body )
assert . EqualValues ( t , 1 , data ( body )[ "point_value" ], "a dry run saves nothing" )
status , body = call ( http . MethodGet , "/manager/loyalty-settings/history" , "" )
require . Equal ( t , http . StatusOK , status , body )
assert . EqualValues ( t , 0 , data ( body )[ "pagination" ].( map [ string ] any )[ "total_count" ])
// Saving records every change with who made it.
status , body = call ( http . MethodPut , "/manager/loyalty-settings" , change )
require . Equal ( t , http . StatusOK , status , body )
assert . Equal ( t , false , data ( body )[ "dry_run" ])
assert . EqualValues ( t , 100 , data ( body )[ "point_value" ])
status , body = call ( http . MethodGet , "/manager/loyalty-settings/history" , "" )
require . Equal ( t , http . StatusOK , status , body )
history := data ( body )[ "data" ].([] any )
require . Len ( t , history , 2 )
var keys [] string
for _ , raw := range history {
row := raw .( map [ string ] any )
keys = append ( keys , row [ "key" ].( string ))
assert . Equal ( t , admin . String (), row [ "changed_by" ])
assert . Nil ( t , row [ "old_value" ], "was on its default" )
assert . Nil ( t , row [ "outlet_id" ])
}
assert . ElementsMatch ( t , [] string { "loyalty.point.value" , "loyalty.exchange.coin_amount" }, keys )
// Nothing already written changes: balances and ledger rows keep their numbers.
var rows [] struct {
Amount int64
Metadata string
}
require . NoError ( t , db . Raw ( `SELECT amount, metadata::text AS metadata FROM wallet_transactions WHERE customer_id = ? ORDER BY currency` , customer ). Scan ( & rows ). Error )
require . Len ( t , rows , 2 )
assert . Equal ( t , int64 ( 105 ), rows [ 0 ]. Amount )
assert . Equal ( t , int64 ( 50000 ), rows [ 1 ]. Amount )
for _ , r := range rows {
assert . Contains ( t , r . Metadata , `"frozen": true` )
}
// Out of bounds, unknown fields and the wrong role are refused.
for name , bad := range map [ string ] string {
"point value 0" : `{"point_value": 0}` ,
"exchange 0" : `{"exchange": {"coin_amount": 0}}` ,
"expiry unit YEAR" : `{"coin_expiry": {"unit": "YEAR"}}` ,
"unknown field" : `{"point_valeu": 5}` ,
} {
status , _ = call ( http . MethodPut , "/manager/loyalty-settings" , bad )
assert . Equal ( t , http . StatusBadRequest , status , name )
status , _ = call ( http . MethodPut , "/manager/loyalty-settings?dry_run=true" , bad )
assert . Equal ( t , http . StatusBadRequest , status , name + " (dry run)" )
}
status , _ = call ( http . MethodPut , "/purchasing/loyalty-settings" , `{"point_value": 5}` )
assert . Equal ( t , http . StatusForbidden , status )
status , body = call ( http . MethodGet , "/manager/loyalty-settings" , "" )
require . Equal ( t , http . StatusOK , status , body )
assert . EqualValues ( t , 100 , data ( body )[ "point_value" ])
2026-10-07 20:53:14 +07:00
// The EnakGame limits (docs/rfc-enakgame.md §5.10) go through the same endpoint and
// history.
status , body = call ( http . MethodPut , "/manager/loyalty-settings" , `{"enakgame": {"user_daily_limit": 50, "global_daily_limit": 10000}}` )
require . Equal ( t , http . StatusOK , status , body )
assert . Equal ( t , map [ string ] any { "user_daily_limit" : float64 ( 50 ), "global_daily_limit" : float64 ( 10000 )}, data ( body )[ "enakgame" ])
var limits [] struct { Key , NewValue string }
require . NoError ( t , db . Raw ( `SELECT key, new_value FROM loyalty_setting_changes WHERE organization_id = ? AND key LIKE 'enakgame.%' ORDER BY key` , org ). Scan ( & limits ). Error )
assert . Equal ( t , [] struct { Key , NewValue string }{{ "enakgame.limit.global_daily" , "10000" }, { "enakgame.limit.user_daily" , "50" }}, limits )
status , _ = call ( http . MethodPut , "/manager/loyalty-settings" , `{"enakgame": {"user_daily_limit": -1}}` )
assert . Equal ( t , http . StatusBadRequest , status )
status , _ = call ( http . MethodPut , "/purchasing/loyalty-settings" , `{"enakgame": {"user_daily_limit": 1}}` )
assert . Equal ( t , http . StatusForbidden , status )
2026-09-30 15:31:44 +07:00
}