package processor import ( "context" "os" "testing" "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" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/repository" ) type auditRepoFake struct{ rows []entities.AuditLog } func (f *auditRepoFake) Insert(_ context.Context, e *entities.AuditLog) error { f.rows = append(f.rows, *e) return nil } func (f *auditRepoFake) ListByEntity(context.Context, uuid.UUID, string, uuid.UUID, int) ([]entities.AuditLog, error) { return f.rows, nil } func validAuditEntry() AuditEntry { return AuditEntry{ OrganizationID: uuid.New(), ActorType: constants.AuditActorUser, ActorID: ptr(uuid.New()), EntityType: constants.AuditEntityGame, EntityID: uuid.New(), Action: "STATUS_CHANGED", Before: map[string]string{"status": "DRAFT"}, After: map[string]string{"status": "ACTIVE"}, Source: constants.AuditSourceAdminAPI, } } func TestAuditLogger_RecordsSnapshotsAsJSON(t *testing.T) { repo := &auditRepoFake{} l := NewAuditLogger(repo) require.NoError(t, l.Record(context.Background(), validAuditEntry())) created := validAuditEntry() created.ActorType, created.ActorID = constants.AuditActorSystem, nil var nothing *entities.Game created.Before = nothing require.NoError(t, l.Record(context.Background(), created)) require.Len(t, repo.rows, 2) assert.JSONEq(t, `{"status": "DRAFT"}`, string(repo.rows[0].Before)) assert.JSONEq(t, `{"status": "ACTIVE"}`, string(repo.rows[0].After)) assert.Nil(t, repo.rows[1].Before, "a nil pointer is stored as NULL, not as JSON null") } func TestAuditLogger_RejectsIncompleteEntries(t *testing.T) { for name, mutate := range map[string]func(*AuditEntry){ "no organization": func(e *AuditEntry) { e.OrganizationID = uuid.Nil }, "unknown actor type": func(e *AuditEntry) { e.ActorType = "ROBOT" }, "USER without actor": func(e *AuditEntry) { e.ActorID = nil }, "no entity type": func(e *AuditEntry) { e.EntityType = " " }, "no entity id": func(e *AuditEntry) { e.EntityID = uuid.Nil }, "no action": func(e *AuditEntry) { e.Action = "" }, "no source": func(e *AuditEntry) { e.Source = "" }, "unmarshallable": func(e *AuditEntry) { e.After = func() {} }, } { repo := &auditRepoFake{} e := validAuditEntry() mutate(&e) err := NewAuditLogger(repo).Record(context.Background(), e) assert.ErrorIs(t, err, ErrInvalidAuditEntry, name) assert.Empty(t, repo.rows, name) } } // An audit row commits and rolls back with the change it records. Needs // TEST_DATABASE_URL; see internal/repository/wallet_repository_test.go. func TestAuditLogger_AgainstPostgres(t *testing.T) { dsn := os.Getenv("TEST_DATABASE_URL") if dsn == "" { t.Skip("TEST_DATABASE_URL not set") } db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) require.NoError(t, err) org := uuid.New() t.Cleanup(func() { db.Exec(`DELETE FROM audit_logs WHERE organization_id = ?`, org) }) repo := repository.NewAuditLogRepository(db) l := NewAuditLogger(repo) txm := repository.NewTxManager(db) entry := func(entityID uuid.UUID) AuditEntry { e := validAuditEntry() e.OrganizationID, e.EntityID, e.Reason = org, entityID, ptr("promo") return e } ctx := context.Background() committed, rolledBack := uuid.New(), uuid.New() require.NoError(t, txm.WithTransaction(ctx, func(ctx context.Context) error { return l.Record(ctx, entry(committed)) })) failure := assert.AnError err = txm.WithTransaction(ctx, func(ctx context.Context) error { require.NoError(t, l.Record(ctx, entry(rolledBack))) return failure }) require.ErrorIs(t, err, failure) rows, err := repo.ListByEntity(ctx, org, constants.AuditEntityGame, committed, 10) require.NoError(t, err) require.Len(t, rows, 1) assert.Equal(t, "STATUS_CHANGED", rows[0].Action) assert.JSONEq(t, `{"status": "DRAFT"}`, string(rows[0].Before)) assert.JSONEq(t, `{"status": "ACTIVE"}`, string(rows[0].After)) assert.Equal(t, "promo", *rows[0].Reason) assert.False(t, rows[0].CreatedAt.IsZero()) rows, err = repo.ListByEntity(ctx, org, constants.AuditEntityGame, rolledBack, 10) require.NoError(t, err) assert.Empty(t, rows, "a rolled back change leaves no audit row") assert.ErrorIs(t, l.Record(ctx, entry(uuid.New())), repository.ErrAuditTxRequired) }