package growthreport import ( "context" "encoding/json" "errors" "fmt" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" ) func TestRepositoryRecoversExpiredActiveLeases(t *testing.T) { pool, merchantID := growthRepositoryTestPool(t) ctx := context.Background() now := time.Now().UTC() for index, status := range []Status{StatusAnalyzing, StatusGenerating} { id := fmt.Sprintf("growth_%024x", index+1) _, err := pool.Exec(ctx, `INSERT INTO growth_reports(id,merchant_id,fingerprint,status,forecast_start_date,dataset_watermark,catalog_version,analytics_version,prompt_version,schema_version,model,lease_token,lease_expires_at,created_at,updated_at) VALUES($1,$2,$3,$4,current_date,'test','test','test','test','test','test',$5,$6,$7,$7)`, id, merchantID, id, status, "expired-token", now.Add(-time.Minute), now.Add(time.Duration(index)*time.Second)) if err != nil { t.Fatal(err) } } repository := NewRepository(pool) first, err := repository.Claim(ctx) if err != nil { t.Fatal(err) } if first.Status != StatusAnalyzing || first.ClaimToken == "" || first.ClaimToken == "expired-token" { t.Fatalf("recovered claim=%+v", first) } if err := repository.CompleteClaimed(ctx, first.ReportID, "expired-token", StatusPartial, json.RawMessage(`{}`), nil, Usage{}, "stale"); !errors.Is(err, ErrNotFound) { t.Fatalf("stale completion error=%v", err) } second, err := repository.Claim(ctx) if err != nil || second.ReportID == first.ReportID || second.ClaimToken == "" { t.Fatalf("second claim=%+v error=%v", second, err) } } func TestRepositoryChecksUncachedAllowanceBeforeInsert(t *testing.T) { pool, merchantID := growthRepositoryTestPool(t) repository := NewRepository(pool) start := time.Now().UTC() input := CreateRecord{ID: "growth_ffffffffffffffffffffffff", MerchantID: merchantID, Fingerprint: "blocked", DatasetWatermark: "test", ForecastStartDate: &start, CatalogVersion: "test", AnalyticsVersion: "test", PromptVersion: "test", SchemaVersion: "test", Model: "test"} _, _, err := repository.CreateOrReuse(context.Background(), input, func() error { return ErrRateLimited }) if !errors.Is(err, ErrRateLimited) { t.Fatalf("CreateOrReuse error=%v", err) } var count int if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM growth_reports WHERE id=$1`, input.ID).Scan(&count); err != nil || count != 0 { t.Fatalf("queued reports=%d error=%v", count, err) } } func TestRepositoryAllowsManualRefreshUntilAISummaryExists(t *testing.T) { pool, merchantID := growthRepositoryTestPool(t) repository := NewRepository(pool) ctx := context.Background() start := time.Now().UTC() refreshDay := tehranMidnight(start) newInput := func(id, fingerprint string) CreateRecord { return CreateRecord{ID: id, MerchantID: merchantID, Fingerprint: fingerprint, DatasetWatermark: "test", ForecastStartDate: &start, CatalogVersion: "test", AnalyticsVersion: "test", PromptVersion: "test", SchemaVersion: "test", Model: "test", ManualRefresh: true, RefreshDay: &refreshDay} } first, _, err := repository.CreateOrReuse(ctx, newInput("growth_aaaaaaaaaaaaaaaaaaaaaaaa", "first"), nil) if err != nil { t.Fatal(err) } if next, err := repository.NextRefreshAt(ctx, merchantID, "", "test", start); err != nil || next.After(start) { t.Fatalf("refresh without AI summary should remain available: next=%s error=%v", next, err) } second, _, err := repository.CreateOrReuse(ctx, newInput("growth_bbbbbbbbbbbbbbbbbbbbbbbb", "second"), nil) if err != nil { t.Fatalf("second refresh without AI summary should be allowed: %v", err) } if _, err := pool.Exec(ctx, `UPDATE growth_reports SET status='READY',ai_response=$2 WHERE id=$1`, second.ReportID, `{"briefs":[{"headline":"خلاصه هوشمند"}]}`); err != nil { t.Fatal(err) } _, _, err = repository.CreateOrReuse(ctx, newInput("growth_cccccccccccccccccccccccc", "third"), nil) if !errors.Is(err, ErrRefreshLimited) { t.Fatalf("refresh after AI summary error=%v, want ErrRefreshLimited (first=%s)", err, first.ReportID) } if next, err := repository.NextRefreshAt(ctx, merchantID, "", "test", start); err != nil || !next.After(start) { t.Fatalf("refresh after AI summary should have a future boundary: next=%s error=%v", next, err) } } func TestRepositoryLatestIgnoresReportsFromOlderAnalyticsVersions(t *testing.T) { pool, merchantID := growthRepositoryTestPool(t) repository := NewRepository(pool) ctx := context.Background() now := time.Now().UTC() _, err := pool.Exec(ctx, `INSERT INTO growth_reports(id,merchant_id,fingerprint,status,forecast_start_date,dataset_watermark,catalog_version,analytics_version,prompt_version,schema_version,model,deterministic_evidence,created_at,updated_at) VALUES($1,$2,'old-fingerprint','PARTIAL',current_date,'test','test','1.1.0','test','test','test',$3,$4,$4)`, "growth_dddddddddddddddddddddddd", merchantID, `{"forecast":{"confidence":"INSUFFICIENT_DATA","points":[]}}`, now) if err != nil { t.Fatal(err) } if _, err := repository.Latest(ctx, merchantID, "", "1.2.0"); !errors.Is(err, ErrNotFound) { t.Fatalf("Latest error=%v, want ErrNotFound for a stale analytics version", err) } if hasSummary, err := repository.HasAISummary(ctx, merchantID, "", "1.2.0"); err != nil || hasSummary { t.Fatalf("stale analytics versions must not count as a visible AI summary: hasSummary=%t error=%v", hasSummary, err) } } func TestRepositoryRefreshQuotaIsScopedPerTerminal(t *testing.T) { pool, merchantID := growthRepositoryTestPool(t) repository := NewRepository(pool) ctx := context.Background() start := time.Now().UTC() refreshDay := tehranMidnight(start) for _, terminal := range []string{"terminal-a", "terminal-b"} { if _, err := pool.Exec(ctx, `INSERT INTO terminals(merchant_id,source_key,verify_type) VALUES($1,$2,'test')`, merchantID, terminal); err != nil { t.Fatal(err) } } newInput := func(id, terminal string) CreateRecord { return CreateRecord{ID: id, MerchantID: merchantID, TerminalID: terminal, Fingerprint: id, DatasetWatermark: "test", ForecastStartDate: &start, CatalogVersion: "test", AnalyticsVersion: "test", PromptVersion: "test", SchemaVersion: "test", Model: "test", ManualRefresh: true, RefreshDay: &refreshDay} } first, _, err := repository.CreateOrReuse(ctx, newInput("growth_eeeeeeeeeeeeeeeeeeeeeeee", "terminal-a"), nil) if err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `UPDATE growth_reports SET status='READY',ai_response=$2 WHERE id=$1`, first.ReportID, `{"briefs":[{"headline":"خلاصه هوشمند"}]}`); err != nil { t.Fatal(err) } if _, _, err := repository.CreateOrReuse(ctx, newInput("growth_ffffffffffffffffffffffff", "terminal-a"), nil); !errors.Is(err, ErrRefreshLimited) { t.Fatalf("same-terminal refresh error=%v, want ErrRefreshLimited", err) } if _, _, err := repository.CreateOrReuse(ctx, newInput("growth_111111111111111111111111", "terminal-b"), nil); err != nil { t.Fatalf("another terminal must retain its own refresh quota: %v", err) } } func growthRepositoryTestPool(t *testing.T) (*pgxpool.Pool, int64) { t.Helper() databaseURL := os.Getenv("TEST_DATABASE_URL") if databaseURL == "" { t.Skip("TEST_DATABASE_URL is not configured") } pool, err := pgxpool.New(context.Background(), databaseURL) if err != nil { t.Fatal(err) } sourceKey := fmt.Sprintf("growth-test-%d", time.Now().UnixNano()) var merchantID int64 if err := pool.QueryRow(context.Background(), `INSERT INTO merchants(source_key,category_id,category_title,display_name) VALUES($1,1,'test','test') RETURNING id`, sourceKey).Scan(&merchantID); err != nil { pool.Close() t.Fatal(err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM growth_reports WHERE merchant_id=$1`, merchantID) _, _ = pool.Exec(context.Background(), `DELETE FROM terminals WHERE merchant_id=$1`, merchantID) _, _ = pool.Exec(context.Background(), `DELETE FROM merchants WHERE id=$1`, merchantID) pool.Close() }) return pool, merchantID }