package repository_test import ( "context" "os" "testing" "time" "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/constants" applogger "apskel-pos-be/internal/logger" "apskel-pos-be/internal/processor" "apskel-pos-be/internal/repository" ) // Reads and writes loyalty settings through the processor against Postgres. Needs // TEST_DATABASE_URL pointing at a migrated database; see wallet_repository_test.go. func TestLoyaltySettings_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) ctx := context.Background() 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 (?, 'loyalty test', 'basic'), (?, 'other', 'basic')`, org, otherOrg) exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org) // A setting that is not a loyalty setting must be left alone. exec(`INSERT INTO outlet_settings (outlet_id, key, value) VALUES (?, 'receipt.footer', 'Terima kasih')`, outlet) 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 IN ?`, []uuid.UUID{org, otherOrg}) db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet) db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg}) }) repo := repository.NewLoyaltySettingsRepository(db) p := processor.NewLoyaltySettingsProcessor(repo, repository.NewTxManager(db)) // No settings: every PRD default. settings, err := p.Outlet(ctx, outlet) require.NoError(t, err) assert.False(t, settings.Point.Enabled) assert.Equal(t, int64(100), settings.Point.EarnPerAmount) assert.Equal(t, int64(25000), settings.Coin.EarnPerAmount) assert.Nil(t, settings.Point.MaxPerOrder) assert.Equal(t, int64(100), settings.PointPayment.MaxPaymentPercent) // Change three keys. settings.Point.Enabled = true settings.Point.EarnPerAmount = 1000 settings.Coin.MaxPerOrder = ptrInt64(5) changes, err := p.UpdateOutlet(ctx, org, outlet, admin, *settings) require.NoError(t, err) assert.Len(t, changes, 3) var stored []struct{ Key, Value string } require.NoError(t, db.Raw(`SELECT key, value FROM outlet_settings WHERE outlet_id = ? ORDER BY key`, outlet).Scan(&stored).Error) assert.Equal(t, []struct{ Key, Value string }{ {constants.LoyaltyCoinMaxPerOrderKey, "5"}, {constants.LoyaltyPointEarnPerAmountKey, "1000"}, {constants.LoyaltyPointEnabledKey, "true"}, {"receipt.footer", "Terima kasih"}, }, stored, "only the changed keys are stored, next to the untouched non-loyalty key") history, err := p.ListChanges(ctx, org, &outlet, 1, 20) require.NoError(t, err) require.Len(t, history.Data, 3) for _, c := range history.Data { assert.Equal(t, admin, c.ChangedBy) assert.Nil(t, c.OldValue, "%s was on its default", c.Key) assert.NotNil(t, c.NewValue) } // A second change records the old value. settings.Point.EarnPerAmount = 500 changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *settings) require.NoError(t, err) require.Len(t, changes, 1) assert.Equal(t, "1000", *changes[0].OldValue) assert.Equal(t, "500", *changes[0].NewValue) // Another organization cannot change this outlet. _, err = p.UpdateOutlet(ctx, otherOrg, outlet, admin, *settings) assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound) // Writing outside a transaction is refused. assert.Error(t, repo.SetOutletValue(ctx, outlet, constants.LoyaltyPointEnabledKey, nil)) // A save must wait while another save of the same organization holds the lock, so // two admins saving at once cannot both compute their change from the same old // value. Hold the lock in another transaction and check the save blocks until it // is released. txm := repository.NewTxManager(db) holding, release, holderDone := make(chan struct{}), make(chan struct{}), make(chan error, 1) go func() { holderDone <- txm.WithTransaction(ctx, func(ctx context.Context) error { if err := repo.LockScope(ctx, org); err != nil { return err } close(holding) <-release return nil }) }() <-holding orgSettings, err := p.Organization(ctx, org) require.NoError(t, err) orgSettings.PointValue = 200 saved := make(chan error, 1) go func() { _, _, err := p.UpdateOrganization(ctx, org, admin, *orgSettings) saved <- err }() select { case err := <-saved: t.Fatalf("the save finished while another transaction held the lock (err: %v)", err) case <-time.After(300 * time.Millisecond): } close(release) require.NoError(t, <-holderDone) require.NoError(t, <-saved) value, err := p.PointValue(ctx, org) require.NoError(t, err) assert.Equal(t, int64(200), value) // The whole organization's history includes the outlet's changes. all, err := p.ListChanges(ctx, org, nil, 1, 100) require.NoError(t, err) assert.EqualValues(t, 5, all.Pagination.Total, "four outlet changes and one organization change") } func ptrInt64(v int64) *int64 { return &v }