Files
zarinpal-analytics-backend/internal/coach/repository_integration_test.go

220 lines
11 KiB
Go

//go:build integration
package coach
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestRepository_EvidenceAnchorsWindowToLatestTerminalData(t *testing.T) {
pool, job := coachRepositoryTestFixture(t)
repository := NewRepository(pool)
_, evidence, err := repository.Evidence(t.Context(), job)
if err != nil {
t.Fatalf("Evidence() error = %v", err)
}
if evidence.DataThrough != "2026-06-30" {
t.Fatalf("Evidence().DataThrough = %q, want 2026-06-30", evidence.DataThrough)
}
if evidence.Sessions != 2 {
t.Fatalf("Evidence().Sessions = %d, want 2", evidence.Sessions)
}
if evidence.Values["paid_count"] != 1 {
t.Fatalf("Evidence().Values[paid_count] = %v, want 1", evidence.Values["paid_count"])
}
}
func TestRepository_CompleteRejectsStaleLeaseBeforeCreatingPlan(t *testing.T) {
pool, job := coachRepositoryTestFixture(t)
job = insertClaimedCoachJob(t, pool, job, "current-lease")
repository := NewRepository(pool)
job.LeaseToken = "stale-lease"
generation := Generation{DailyBrief: "brief", Suggestions: []GenerationSuggestion{{ActionType: "MANUAL", Title: "action", Rationale: "reason", ExpectedImpact: "impact", Effort: "LOW", EvidenceIDs: []string{"payment-health"}}}}
err := repository.Complete(t.Context(), job, generation, MetricEvidence{}, ProviderUsage{}, "test-model")
if !errors.Is(err, ErrNotFound) {
t.Fatalf("Complete() error = %v, want ErrNotFound", err)
}
var plans int
if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM growth_coach_plans WHERE merchant_id=$1`, job.MerchantID).Scan(&plans); err != nil {
t.Fatal(err)
}
if plans != 0 {
t.Fatalf("stale completion created %d plans, want 0", plans)
}
}
func TestRepository_FailRejectsStaleLeaseWithoutEvent(t *testing.T) {
pool, job := coachRepositoryTestFixture(t)
job = insertClaimedCoachJob(t, pool, job, "current-lease")
repository := NewRepository(pool)
job.LeaseToken = "stale-lease"
err := repository.Fail(t.Context(), job, "EVIDENCE_FAILED")
if !errors.Is(err, ErrNotFound) {
t.Fatalf("Fail() error = %v, want ErrNotFound", err)
}
var events int
if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM growth_coach_events WHERE merchant_id=$1 AND entity_id=$2 AND event_type='job.failed'`, job.MerchantID, job.JobID).Scan(&events); err != nil {
t.Fatal(err)
}
if events != 0 {
t.Fatalf("stale failure created %d events, want 0", events)
}
}
func TestRepository_CompletePersistsTheEvidenceSentToGenerator(t *testing.T) {
pool, job := coachRepositoryTestFixture(t)
job = insertClaimedCoachJob(t, pool, job, "current-lease")
repository := NewRepository(pool)
_, evidence, err := repository.Evidence(t.Context(), job)
if err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(t.Context(), `DELETE FROM payment_sessions WHERE merchant_id=$1`, job.MerchantID); err != nil {
t.Fatal(err)
}
generation := Generation{DailyBrief: "brief", Suggestions: []GenerationSuggestion{{ActionType: "MANUAL", Title: "action", Rationale: "reason", ExpectedImpact: "impact", Effort: "LOW", EvidenceIDs: []string{"payment-health"}}}}
if err := repository.Complete(t.Context(), job, generation, evidence, ProviderUsage{}, "test-model"); err != nil {
t.Fatal(err)
}
var sampleSize int64
if err := pool.QueryRow(t.Context(), `SELECT sample_size FROM growth_coach_recommendations WHERE merchant_id=$1`, job.MerchantID).Scan(&sampleSize); err != nil {
t.Fatal(err)
}
if sampleSize != evidence.Sessions {
t.Fatalf("persisted sample size = %d, want generated evidence sample size %d", sampleSize, evidence.Sessions)
}
}
func TestRepository_TransitionSubtaskCompletesSubtaskWithoutEvent(t *testing.T) {
pool, job := coachRepositoryTestFixture(t)
repository := NewRepository(pool)
ctx := t.Context()
var userID int64
if err := pool.QueryRow(ctx, `SELECT id FROM merchant_users WHERE merchant_id=$1`, job.MerchantID).Scan(&userID); err != nil {
t.Fatal(err)
}
suffix := time.Now().UnixNano()
planID := fmt.Sprintf("plan_test_%d", suffix)
recommendationID := fmt.Sprintf("recommendation_test_%d", suffix)
goalID := fmt.Sprintf("goal_test_%d", suffix)
subtaskID := fmt.Sprintf("subtask_test_%d", suffix)
if _, err := pool.Exec(ctx, `INSERT INTO growth_coach_plans(id,merchant_id,terminal_id,terminal_source_key,profile_id,cadence,status,period_start,period_end,difficulty,prompt_version,schema_version,model) VALUES($1,$2,$3,$4,$5,'MONTHLY','READY',current_date,current_date+29,2,'test','test','test')`, planID, job.MerchantID, job.TerminalDBID, job.TerminalID, job.ProfileID); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO growth_coach_recommendations(id,plan_id,merchant_id,terminal_id,rank,status,action_type,title,rationale,expected_impact,effort,evidence_ids,metric_id,metric_direction,action_level,baseline,target,sample_size,deadline,decided_by,decided_at) VALUES($1,$2,$3,$4,1,'ACCEPTED','METRIC','Goal','Reason','Impact','LOW','[]','paid_count','INCREASE','SMALL',1,2,10,current_date+29,$5,now())`, recommendationID, planID, job.MerchantID, job.TerminalDBID, userID); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO monthly_goals(id,recommendation_id,merchant_id,terminal_id,terminal_source_key,profile_id,title,expected_impact,status,metric_id,metric_direction,baseline,target,current_value,sample_size,deadline,actor_user_id) VALUES($1,$2,$3,$4,$5,$6,'Goal','Impact','ACTIVE','paid_count','INCREASE',1,2,1,10,current_date+29,$7)`, goalID, recommendationID, job.MerchantID, job.TerminalDBID, job.TerminalID, job.ProfileID, userID); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO goal_subtasks(id,goal_id,merchant_id,position,title,status,deadline,event_id) VALUES($1,$2,$3,1,'Concrete action','TODO',current_date,NULL)`, subtaskID, goalID, job.MerchantID); err != nil {
t.Fatal(err)
}
got, err := repository.TransitionSubtask(ctx, job.MerchantID, userID, goalID, subtaskID, "subtask-transition-test", hashJSON(map[string]any{"goalId": goalID, "subtaskId": subtaskID, "transition": "COMPLETE"}), "COMPLETE")
if err != nil {
t.Fatalf("TransitionSubtask() error = %v", err)
}
if got.Status != "DONE" || got.EventID != nil || got.CompletedAt == nil {
t.Fatalf("TransitionSubtask() = %+v, want completed subtask without event", got)
}
}
func insertClaimedCoachJob(t *testing.T, pool *pgxpool.Pool, job claimedJob, leaseToken string) claimedJob {
t.Helper()
job.JobID = newID("coachjob_test_")
job.LeaseToken = leaseToken
job.Cadence = "DAILY"
job.Start = time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC)
job.End = job.Start
_, err := pool.Exec(t.Context(), `INSERT INTO growth_coach_jobs(id,merchant_id,terminal_id,terminal_source_key,profile_id,cadence,trigger_reason,period_start,period_end,fingerprint,status,lease_token,lease_expires_at) VALUES($1,$2,$3,$4,$5,$6,'TEST',$7,$8,$9,'RUNNING',$10,now()+interval '3 minutes')`, job.JobID, job.MerchantID, job.TerminalDBID, job.TerminalID, job.ProfileID, job.Cadence, job.Start, job.End, job.JobID, leaseToken)
if err != nil {
t.Fatal(err)
}
return job
}
func coachRepositoryTestFixture(t *testing.T) (*pgxpool.Pool, claimedJob) {
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)
}
ctx := t.Context()
suffix := time.Now().UnixNano()
var importRunID, merchantID, terminalID, userID int64
if err := pool.QueryRow(ctx, `INSERT INTO import_runs(source_sha256,source_path,status) VALUES($1,'coach-test','COMPLETED') RETURNING id`, fmt.Sprintf("%064x", suffix)).Scan(&importRunID); err != nil {
pool.Close()
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `INSERT INTO merchants(source_key,category_id,category_title,display_name) VALUES($1,1,'test','test') RETURNING id`, fmt.Sprintf("coach-test-%d", suffix)).Scan(&merchantID); err != nil {
pool.Close()
t.Fatal(err)
}
terminalKey := fmt.Sprintf("coach-terminal-%d", suffix)
if err := pool.QueryRow(ctx, `INSERT INTO terminals(merchant_id,source_key,verify_type) VALUES($1,$2,'test') RETURNING id`, merchantID, terminalKey).Scan(&terminalID); err != nil {
pool.Close()
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `INSERT INTO merchant_users(merchant_id,username,password_hash) VALUES($1,$2,'test') RETURNING id`, merchantID, fmt.Sprintf("coach-user-%d", suffix)).Scan(&userID); err != nil {
pool.Close()
t.Fatal(err)
}
profileID := fmt.Sprintf("profile_test_%d", suffix)
if _, err := pool.Exec(ctx, `INSERT INTO growth_coach_profiles(id,merchant_id,terminal_id,terminal_source_key,version,business_description,niches,sales_channels,primary_goal,actor_user_id) VALUES($1,$2,$3,$4,1,'Test merchant business description','["test"]','["web"]','Test growth goal',$5)`, profileID, merchantID, terminalID, terminalKey, userID); err != nil {
pool.Close()
t.Fatal(err)
}
for index, session := range []struct {
status string
createdAt time.Time
}{
{status: "Failed", createdAt: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)},
{status: "Verified", createdAt: time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)},
} {
if _, err := pool.Exec(ctx, `INSERT INTO payment_sessions(import_run_id,merchant_id,terminal_id,source_key,status,amount_rial,fee_amount_rial,created_at) VALUES($1,$2,$3,$4,$5,100000,1000,$6)`, importRunID, merchantID, terminalID, fmt.Sprintf("coach-session-%d-%d", suffix, index), session.status, session.createdAt); err != nil {
pool.Close()
t.Fatal(err)
}
}
t.Cleanup(func() {
cleanupCtx := context.Background()
_, _ = pool.Exec(cleanupCtx, `DELETE FROM growth_coach_events WHERE merchant_id=$1`, merchantID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM growth_coach_idempotency WHERE merchant_id=$1`, merchantID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM goal_deadline_proposals WHERE merchant_id=$1`, merchantID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM goal_subtasks WHERE merchant_id=$1`, merchantID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM monthly_goals WHERE merchant_id=$1`, merchantID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM golden_steps WHERE merchant_id=$1`, merchantID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM growth_coach_recommendations WHERE merchant_id=$1`, merchantID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM growth_coach_plans WHERE merchant_id=$1`, merchantID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM growth_coach_jobs WHERE merchant_id=$1`, merchantID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM growth_coach_profiles WHERE merchant_id=$1`, merchantID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM payment_sessions WHERE merchant_id=$1`, merchantID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM merchant_users WHERE merchant_id=$1`, merchantID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM terminals WHERE merchant_id=$1`, merchantID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM merchants WHERE id=$1`, merchantID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM import_runs WHERE id=$1`, importRunID)
pool.Close()
})
return pool, claimedJob{MerchantID: merchantID, TerminalDBID: terminalID, TerminalID: terminalKey, ProfileID: profileID}
}