package auditlog_test import ( "context" "sync" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" "github.com/veylant/ia-gateway/internal/auditlog" ) // ─── MemLogger tests ────────────────────────────────────────────────────────── func TestMemLogger_Log_And_Entries(t *testing.T) { ml := auditlog.NewMemLogger() ml.Log(auditlog.AuditEntry{RequestID: "req-1", TenantID: "t1"}) ml.Log(auditlog.AuditEntry{RequestID: "req-2", TenantID: "t1"}) entries := ml.Entries() assert.Len(t, entries, 2) assert.Equal(t, "req-1", entries[0].RequestID) } func TestMemLogger_Query_FiltersByTenant(t *testing.T) { ml := auditlog.NewMemLogger() ml.Log(auditlog.AuditEntry{TenantID: "t1", RequestID: "a", SensitivityLevel: "low"}) ml.Log(auditlog.AuditEntry{TenantID: "t2", RequestID: "b", SensitivityLevel: "high"}) result, err := ml.Query(context.Background(), auditlog.AuditQuery{TenantID: "t1", Limit: 10}) require.NoError(t, err) assert.Len(t, result.Data, 1) assert.Equal(t, "a", result.Data[0].RequestID) } func TestMemLogger_Query_FiltersByMinSensitivity(t *testing.T) { ml := auditlog.NewMemLogger() ml.Log(auditlog.AuditEntry{TenantID: "t1", RequestID: "none", SensitivityLevel: "none"}) ml.Log(auditlog.AuditEntry{TenantID: "t1", RequestID: "low", SensitivityLevel: "low"}) ml.Log(auditlog.AuditEntry{TenantID: "t1", RequestID: "high", SensitivityLevel: "high"}) ml.Log(auditlog.AuditEntry{TenantID: "t1", RequestID: "critical", SensitivityLevel: "critical"}) result, err := ml.Query(context.Background(), auditlog.AuditQuery{ TenantID: "t1", MinSensitivity: "high", Limit: 10, }) require.NoError(t, err) assert.Len(t, result.Data, 2) } func TestMemLogger_Query_Pagination(t *testing.T) { ml := auditlog.NewMemLogger() for i := 0; i < 10; i++ { ml.Log(auditlog.AuditEntry{TenantID: "t1"}) } result, err := ml.Query(context.Background(), auditlog.AuditQuery{ TenantID: "t1", Limit: 3, Offset: 5, }) require.NoError(t, err) assert.Len(t, result.Data, 3) assert.Equal(t, 10, result.Total) } func TestMemLogger_QueryCosts_GroupByProvider(t *testing.T) { ml := auditlog.NewMemLogger() ml.Log(auditlog.AuditEntry{TenantID: "t1", Provider: "openai", TokenTotal: 1000, CostUSD: 0.005}) ml.Log(auditlog.AuditEntry{TenantID: "t1", Provider: "openai", TokenTotal: 500, CostUSD: 0.0025}) ml.Log(auditlog.AuditEntry{TenantID: "t1", Provider: "ollama", TokenTotal: 2000, CostUSD: 0}) ml.Log(auditlog.AuditEntry{TenantID: "t2", Provider: "openai", TokenTotal: 1000, CostUSD: 0.005}) result, err := ml.QueryCosts(context.Background(), auditlog.CostQuery{ TenantID: "t1", GroupBy: "provider", }) require.NoError(t, err) assert.Len(t, result.Data, 2) // Find openai summary var openaiSummary auditlog.CostSummary for _, s := range result.Data { if s.Key == "openai" { openaiSummary = s } } assert.Equal(t, 1500, openaiSummary.TotalTokens) assert.InDelta(t, 0.0075, openaiSummary.TotalCostUSD, 1e-9) assert.Equal(t, 2, openaiSummary.RequestCount) } // ─── BatchWriter tests ──────────────────────────────────────────────────────── // mockFlusher records received batches for assertions. type mockFlusher struct { mu sync.Mutex batches [][]auditlog.AuditEntry total int } func (f *mockFlusher) InsertBatch(_ context.Context, entries []auditlog.AuditEntry) error { f.mu.Lock() defer f.mu.Unlock() cp := make([]auditlog.AuditEntry, len(entries)) copy(cp, entries) f.batches = append(f.batches, cp) f.total += len(entries) return nil } func (f *mockFlusher) Total() int { f.mu.Lock() defer f.mu.Unlock() return f.total } func TestBatchWriter_FlushOnSize(t *testing.T) { flusher := &mockFlusher{} bw := auditlog.NewBatchWriterForTest(flusher, 5, 10*time.Second, zap.NewNop()) bw.Start() defer bw.Stop() for i := 0; i < 5; i++ { bw.Log(auditlog.AuditEntry{RequestID: "r"}) } // Wait for flush to happen (should be almost immediate on batch size). require.Eventually(t, func() bool { return flusher.Total() == 5 }, 2*time.Second, 10*time.Millisecond, "expected 5 entries flushed") } func TestBatchWriter_FlushOnTick(t *testing.T) { flusher := &mockFlusher{} bw := auditlog.NewBatchWriterForTest(flusher, 100, 50*time.Millisecond, zap.NewNop()) bw.Start() defer bw.Stop() // Send only 3 entries (below batch size). for i := 0; i < 3; i++ { bw.Log(auditlog.AuditEntry{RequestID: "r"}) } require.Eventually(t, func() bool { return flusher.Total() == 3 }, 500*time.Millisecond, 10*time.Millisecond, "expected tick flush") } func TestBatchWriter_Stop_DrainsPending(t *testing.T) { flusher := &mockFlusher{} bw := auditlog.NewBatchWriterForTest(flusher, 1000, 10*time.Second, zap.NewNop()) bw.Start() for i := 0; i < 7; i++ { bw.Log(auditlog.AuditEntry{RequestID: "r"}) } bw.Stop() assert.Equal(t, 7, flusher.Total(), "Stop should drain remaining entries") } func TestBatchWriter_OverflowDrops(t *testing.T) { // Flusher that blocks forever to force channel fill. var called atomic.Bool blockFlusher := &blockingFlusher{called: &called} // Very small channel to trigger overflow quickly. bw := auditlog.NewBatchWriterForTest(blockFlusher, 1, 10*time.Millisecond, zap.NewNop()) bw.Start() defer bw.Stop() // First entry triggers flush (which blocks); additional entries should fill channel. // With cap=10_000 we can't easily fill it in a unit test, so we just verify // that Log() returns immediately (non-blocking) even when the flusher is slow. start := time.Now() for i := 0; i < 20; i++ { bw.Log(auditlog.AuditEntry{RequestID: "r"}) } assert.Less(t, time.Since(start), 200*time.Millisecond, "Log should be non-blocking") } // blockingFlusher blocks for 5 seconds to simulate a slow ClickHouse. type blockingFlusher struct { called *atomic.Bool } func (b *blockingFlusher) InsertBatch(ctx context.Context, _ []auditlog.AuditEntry) error { b.called.Store(true) select { case <-ctx.Done(): case <-time.After(5 * time.Second): } return nil } func TestBatchWriter_ConcurrentLog(t *testing.T) { flusher := &mockFlusher{} bw := auditlog.NewBatchWriterForTest(flusher, 50, 20*time.Millisecond, zap.NewNop()) bw.Start() defer bw.Stop() var wg sync.WaitGroup for g := 0; g < 10; g++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < 10; i++ { bw.Log(auditlog.AuditEntry{RequestID: "r"}) } }() } wg.Wait() require.Eventually(t, func() bool { return flusher.Total() == 100 }, 2*time.Second, 10*time.Millisecond) }