package invoice_test import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "github.com/noderunners/nip05api/internal/db" "github.com/noderunners/nip05api/internal/invoice" "github.com/noderunners/nip05api/internal/user" ) const testHex = "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c" func newServiceFixture(t *testing.T) (*invoice.Service, *user.Service, *httptest.Server) { t.Helper() d, err := db.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } if err := d.Migrate(context.Background()); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = d.Close() }) ln := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]string{ "payment_hash": "hash-" + r.URL.Path, "payment_request": "lnbcfake", }) })) t.Cleanup(ln.Close) users := user.NewService(user.NewRepo(d), []string{"admin", "root"}) client := invoice.NewLNbits(ln.URL, "key") pricing := invoice.Pricing{YearlySats: 1000, LifetimeSats: 5000, ExpiryMins: 30} svc := invoice.NewService(invoice.NewRepo(d), users, client, pricing, "test.local") return svc, users, ln } func TestCreate_PubkeyOnlyDefaultsLifetime(t *testing.T) { svc, _, _ := newServiceFixture(t) p, err := svc.Create(context.Background(), invoice.CreateRequest{ Pubkey: testHex, SubscriptionType: user.SubLifetime, }) if err != nil { t.Fatalf("create: %v", err) } if p.SubscriptionType != user.SubLifetime { t.Errorf("expected lifetime, got %q", p.SubscriptionType) } if p.AmountSats != 5000 { t.Errorf("expected lifetime price, got %d", p.AmountSats) } if !strings.HasPrefix(p.Username, "u_") { t.Errorf("expected provisional username, got %q", p.Username) } if err := user.ValidateUsername(p.Username, nil); err != nil { t.Errorf("provisional name should be valid: %v", err) } } func TestCreate_YearlyDefaultsOneYear(t *testing.T) { svc, _, _ := newServiceFixture(t) p, err := svc.Create(context.Background(), invoice.CreateRequest{ Pubkey: testHex, Username: "alice", SubscriptionType: user.SubYearly, Years: 1, }) if err != nil { t.Fatalf("create: %v", err) } if p.Years != 1 || p.AmountSats != 1000 { t.Errorf("unexpected pending: years=%d amount=%d", p.Years, p.AmountSats) } if p.Username != "alice" { t.Errorf("expected explicit username, got %q", p.Username) } } func TestCreate_RenewalReusesUsername(t *testing.T) { svc, users, _ := newServiceFixture(t) if _, err := users.CreateOrActivate(context.Background(), testHex, "alice", user.SubYearly, 1, false); err != nil { t.Fatal(err) } p, err := svc.Create(context.Background(), invoice.CreateRequest{ Pubkey: testHex, SubscriptionType: user.SubLifetime, }) if err != nil { t.Fatalf("create: %v", err) } if !p.IsRenewal { t.Error("expected renewal flag") } if p.Username != "alice" { t.Errorf("expected stored username, got %q", p.Username) } } func TestCreate_BlocksActiveLifetimeUser(t *testing.T) { svc, users, _ := newServiceFixture(t) if _, err := users.CreateOrActivate(context.Background(), testHex, "alice", user.SubLifetime, 0, false); err != nil { t.Fatal(err) } _, err := svc.Create(context.Background(), invoice.CreateRequest{ Pubkey: testHex, SubscriptionType: user.SubYearly, Years: 1, }) if !errors.Is(err, invoice.ErrLifetimeAccess) { t.Fatalf("expected ErrLifetimeAccess, got %v", err) } } func TestCreate_ResumesDuplicatePending(t *testing.T) { svc, _, _ := newServiceFixture(t) first, err := svc.Create(context.Background(), invoice.CreateRequest{ Pubkey: testHex, Username: "alice", SubscriptionType: user.SubYearly, Years: 1, }) if err != nil { t.Fatalf("first create: %v", err) } second, err := svc.Create(context.Background(), invoice.CreateRequest{ Pubkey: testHex, Username: "alice", SubscriptionType: user.SubYearly, Years: 1, }) if err != nil { t.Fatalf("second create should resume pending: %v", err) } if second.PaymentHash != first.PaymentHash || second.PaymentRequest != first.PaymentRequest { t.Fatalf("expected same pending invoice on resume, got hash=%q vs %q", second.PaymentHash, first.PaymentHash) } } func TestCreate_SupersedesDifferentPendingPlan(t *testing.T) { svc, _, _ := newServiceFixture(t) first, err := svc.Create(context.Background(), invoice.CreateRequest{ Pubkey: testHex, Username: "alice", SubscriptionType: user.SubLifetime, }) if err != nil { t.Fatalf("lifetime pending: %v", err) } second, err := svc.Create(context.Background(), invoice.CreateRequest{ Pubkey: testHex, Username: "alice", SubscriptionType: user.SubYearly, Years: 1, }) if err != nil { t.Fatalf("yearly after lifetime pending: %v", err) } stored, err := svc.Repo().Get(context.Background(), second.PaymentHash) if err != nil { t.Fatal(err) } if stored.SubscriptionType != user.SubYearly || stored.Years != 1 || stored.AmountSats != 1000 { t.Fatalf("DB row should reflect yearly plan after supersede: %+v", stored) } if second.SubscriptionType != user.SubYearly || second.Years != 1 || second.AmountSats != 1000 { t.Fatalf("unexpected yearly invoice: %+v", second) } if first.SubscriptionType == second.SubscriptionType || first.AmountSats == second.AmountSats { t.Fatalf("expected plan switch lifetime -> yearly") } } func TestCreate_YearlyPersistsTargetExpiry(t *testing.T) { svc, users, _ := newServiceFixture(t) if _, err := users.CreateOrActivate(context.Background(), testHex, "alice", user.SubYearly, 1, false); err != nil { t.Fatal(err) } existing, err := users.Repo().GetByPubkey(context.Background(), testHex) if err != nil { t.Fatal(err) } before := time.Now().UTC() p, err := svc.Create(context.Background(), invoice.CreateRequest{ Pubkey: testHex, SubscriptionType: user.SubYearly, Years: 1, }) if err != nil { t.Fatalf("create: %v", err) } stored, err := svc.Repo().Get(context.Background(), p.PaymentHash) if err != nil { t.Fatal(err) } if !stored.TargetSet || stored.TargetExpiresAt == nil { t.Fatalf("expected target persisted, got set=%v at=%v", stored.TargetSet, stored.TargetExpiresAt) } want := existing.ExpiresAt.AddDate(1, 0, 0) if !stored.TargetExpiresAt.Equal(want) { t.Errorf("target stacking mismatch: got %v want %v", stored.TargetExpiresAt, want) } if stored.TargetExpiresAt.Before(before) { t.Error("target should be in the future") } } func TestCreate_YearlyExpiredUserStartsFromNow(t *testing.T) { svc, users, _ := newServiceFixture(t) if _, err := users.CreateOrActivate(context.Background(), testHex, "alice", user.SubYearly, 1, false); err != nil { t.Fatal(err) } u, err := users.Repo().GetByPubkey(context.Background(), testHex) if err != nil { t.Fatal(err) } past := time.Now().UTC().AddDate(0, 0, -1) u.ExpiresAt = &past if err := users.Repo().Update(context.Background(), u); err != nil { t.Fatal(err) } before := time.Now().UTC() p, err := svc.Create(context.Background(), invoice.CreateRequest{ Pubkey: testHex, SubscriptionType: user.SubYearly, Years: 1, }) if err != nil { t.Fatalf("create: %v", err) } stored, err := svc.Repo().Get(context.Background(), p.PaymentHash) if err != nil { t.Fatal(err) } if stored.TargetExpiresAt == nil { t.Fatal("expected target") } want := before.AddDate(1, 0, 0) delta := stored.TargetExpiresAt.Sub(want) if delta < -2*time.Second || delta > 2*time.Second { t.Errorf("expected ~now+1y (%v), got %v", want, stored.TargetExpiresAt) } }