| 1 | package abono_test |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "net/http" |
| 7 | "sort" |
| 8 | "strings" |
| 9 | "sync" |
| 10 | "testing" |
| 11 | |
| 12 | "gorm.io/gorm" |
| 13 | |
| 14 | "amadan.net/rastrillo/abono" |
| 15 | ) |
| 16 | |
| 17 | func TestAdoptAddsTheLegacyPriceAndSyncs(t *testing.T) { |
| 18 | now := t0 |
| 19 | d := newStripeDouble(t) |
| 20 | rec := &recorder{subject: "42"} |
| 21 | s, db := d.service(t, &now, rec.config()) |
| 22 | seedPlans(t, s) |
| 23 | ctx := context.Background() |
| 24 | |
| 25 | // A WordPress-era subscription on a price no plan knows. |
| 26 | d.put(t, subSpec{ID: "sub_wp", Status: "active", Price: "price_wp", Interval: "year", Customer: "cus_wp", Email: "wp@example.com", End: t0.Add(100 * day)}) |
| 27 | if err := s.Adopt(ctx, "sub_wp", "3", "annual"); err != nil { |
| 28 | t.Fatalf("Adopt: %v", err) |
| 29 | } |
| 30 | if got := db.dump("SELECT plan, sold, interval FROM abono_prices WHERE stripe_price_id = 'price_wp'"); got != "annual|0|year\n" { |
| 31 | t.Errorf("price_wp = %q, want a legacy price of annual", got) |
| 32 | } |
| 33 | if got, want := db.dump(rowsQuery), "sub_wp|cus_wp|3|annual|price_wp|active|0|wp@example.com\n"; got != want { |
| 34 | t.Errorf("rows =\n%s\nwant\n%s", got, want) |
| 35 | } |
| 36 | if !hasPlan(t, s, "3", "annual") { |
| 37 | t.Error("HasPlan(3, annual) is false after Adopt") |
| 38 | } |
| 39 | asked, changes := rec.take() |
| 40 | eq(t, "Holder asked", asked, nil) |
| 41 | eq(t, "OnChange", changes, []string{"3"}) |
| 42 | |
| 43 | // Adopting it again is a no-op. |
| 44 | if err := s.Adopt(ctx, "sub_wp", "3", "annual"); err != nil { |
| 45 | t.Fatalf("Adopt again: %v", err) |
| 46 | } |
| 47 | if _, changes := rec.take(); len(changes) != 0 { |
| 48 | t.Errorf("a second Adopt told %q", changes) |
| 49 | } |
| 50 | |
| 51 | // A price already on another plan is refused, and nothing is written. |
| 52 | d.put(t, subSpec{ID: "sub_m", Status: "active", Price: "price_M", Customer: "cus_m", Email: "m@example.com", End: t0.Add(10 * day)}) |
| 53 | if err := s.Adopt(ctx, "sub_m", "3", "annual"); err == nil { |
| 54 | t.Error("Adopt onto annual of a subscription on monthly's price succeeded") |
| 55 | } |
| 56 | if got := db.dump("SELECT plan, sold FROM abono_prices WHERE stripe_price_id = 'price_M'"); got != "monthly|1\n" { |
| 57 | t.Errorf("price_M = %q, want it left on monthly", got) |
| 58 | } |
| 59 | if got := db.dump("SELECT * FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_m'"); got != "" { |
| 60 | t.Errorf("a refused Adopt wrote a row: %q", got) |
| 61 | } |
| 62 | |
| 63 | if err := s.Adopt(ctx, "sub_wp", "3", "nonesuch"); !errors.Is(err, abono.ErrNotFound) { |
| 64 | t.Errorf("Adopt onto a plan with no offer: err = %v, want ErrNotFound", err) |
| 65 | } |
| 66 | if err := s.Adopt(ctx, "sub_gone", "3", "annual"); err == nil { |
| 67 | t.Error("Adopt of a subscription Stripe does not have succeeded") |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | // TestAdoptWithoutSubjectFindsTheHolder is ruling R7: an unrecognised |
| 72 | // row usually has no subject, so Adopt finds one as sync does. |
| 73 | func TestAdoptWithoutSubjectFindsTheHolder(t *testing.T) { |
| 74 | now := t0 |
| 75 | d := newStripeDouble(t) |
| 76 | rec := &recorder{subject: "42"} |
| 77 | s, db := d.service(t, &now, rec.config()) |
| 78 | seedPlans(t, s) |
| 79 | ctx := context.Background() |
| 80 | |
| 81 | sub := d.put(t, subSpec{ID: "sub_z", Status: "active", Price: "price_Z", Customer: "cus_z", Email: "zed@example.com", End: t0.Add(30 * day)}) |
| 82 | if err := s.Sync(ctx, sub, abono.Hint{Email: "zed@example.com"}); err != nil { |
| 83 | t.Fatalf("Sync: %v", err) |
| 84 | } |
| 85 | if asked, _ := rec.take(); len(asked) != 0 { |
| 86 | t.Fatalf("Holder asked %q for an unrecognised price", asked) |
| 87 | } |
| 88 | |
| 89 | if err := s.Adopt(ctx, "sub_z", "", "monthly"); err != nil { |
| 90 | t.Fatalf("Adopt with no subject: %v", err) |
| 91 | } |
| 92 | if got, want := db.dump(rowsQuery), "sub_z|cus_z|42|monthly|price_Z|active|0|zed@example.com\n"; got != want { |
| 93 | t.Errorf("rows =\n%s\nwant\n%s", got, want) |
| 94 | } |
| 95 | asked, changes := rec.take() |
| 96 | eq(t, "Holder asked", asked, []string{"zed@example.com"}) |
| 97 | eq(t, "OnChange", changes, []string{"42"}) |
| 98 | if un, err := s.Unrecognised(ctx); err != nil || len(un) != 0 { |
| 99 | t.Errorf("Unrecognised after Adopt = %+v, %v; want none", un, err) |
| 100 | } |
| 101 | |
| 102 | // A customer already mapped decides before Holder is asked. |
| 103 | db.exec(`INSERT INTO abono_customers (stripe_customer_id, subject) VALUES ('cus_y', '8')`) |
| 104 | d.put(t, subSpec{ID: "sub_y", Status: "active", Price: "price_Y", Customer: "cus_y", Email: "why@example.com", End: t0.Add(30 * day)}) |
| 105 | if err := s.Adopt(ctx, "sub_y", "", "monthly"); err != nil { |
| 106 | t.Fatalf("Adopt of a mapped customer: %v", err) |
| 107 | } |
| 108 | if got := db.dump("SELECT subject FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_y'"); got != "8\n" { |
| 109 | t.Errorf("sub_y subject = %q, want 8", got) |
| 110 | } |
| 111 | if asked, _ := rec.take(); len(asked) != 0 { |
| 112 | t.Errorf("Holder asked %q for a mapped customer", asked) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | // TestAnOwnersAdoptionOutlastsLaterHints is final review I3 (ruling R36): |
| 117 | // A bought the subscription signed in, so its metadata and its Checkout |
| 118 | // session both name A. The owner then adopts it for B. Later events, a |
| 119 | // second visit to Checkout's return and the reconcile all carry A as a |
| 120 | // hint; none of them gives it back to A. Claim pins a row the same way. |
| 121 | func TestAnOwnersAdoptionOutlastsLaterHints(t *testing.T) { |
| 122 | now := t0 |
| 123 | d := newStripeDouble(t) |
| 124 | rec := &recorder{} |
| 125 | s, db := d.service(t, &now, rec.config()) |
| 126 | seedPlans(t, s) |
| 127 | ctx := context.Background() |
| 128 | |
| 129 | d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", |
| 130 | End: t0.Add(30 * day), Metadata: map[string]string{"abono_subject": "A"}}) |
| 131 | d.session("cs_1", "complete", "sub_1", "A", "ana@example.com") |
| 132 | if _, err := s.Finish(ctx, "cs_1"); err != nil { |
| 133 | t.Fatalf("Finish: %v", err) |
| 134 | } |
| 135 | if err := s.Adopt(ctx, "sub_1", "B", "annual"); err != nil { |
| 136 | t.Fatalf("Adopt for B: %v", err) |
| 137 | } |
| 138 | holder := func(when string) { |
| 139 | t.Helper() |
| 140 | if got := db.dump(`SELECT subject FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_1'`); got != "B\n" { |
| 141 | t.Errorf("%s: subject = %q, want B, whom the owner adopted it for", when, got) |
| 142 | } |
| 143 | if got := db.dump(`SELECT subject FROM abono_customers WHERE stripe_customer_id = 'cus_1'`); got != "B\n" { |
| 144 | t.Errorf("%s: cus_1 is mapped to %q, want B", when, got) |
| 145 | } |
| 146 | } |
| 147 | holder("after Adopt") |
| 148 | |
| 149 | wantStatus(t, deliver(t, s, event("evt_1", "customer.subscription.updated", map[string]any{"id": "sub_1"})), http.StatusOK) |
| 150 | holder("after a subscription event naming A") |
| 151 | wantStatus(t, deliver(t, s, event("evt_2", "checkout.session.completed", |
| 152 | map[string]any{"id": "cs_1", "subscription": "sub_1", "client_reference_id": "A"})), http.StatusOK) |
| 153 | holder("after a Checkout event naming A") |
| 154 | if f, err := s.Finish(ctx, "cs_1"); err != nil || f.Subject != "B" { |
| 155 | t.Errorf("Finish again = %+v, %v; want B's", f, err) |
| 156 | } |
| 157 | holder("after Checkout's return again") |
| 158 | if _, err := s.Reconcile(ctx); err != nil { |
| 159 | t.Fatalf("Reconcile: %v", err) |
| 160 | } |
| 161 | holder("after Reconcile") |
| 162 | if hasPlan(t, s, "A", "annual") || !hasPlan(t, s, "B", "annual") { |
| 163 | t.Error("the plan went back to A") |
| 164 | } |
| 165 | |
| 166 | // Claim is an explicit decision too. |
| 167 | d.put(t, subSpec{ID: "sub_2", Status: "active", Price: "price_M", Customer: "cus_2", Email: "walkin@example.com", End: t0.Add(30 * day)}) |
| 168 | if err := s.Sync(ctx, d.sub(t, "sub_2"), abono.Hint{}); err != nil { |
| 169 | t.Fatalf("Sync: %v", err) |
| 170 | } |
| 171 | if n, err := s.Claim(ctx, "C", "walkin@example.com"); err != nil || n != 1 { |
| 172 | t.Fatalf("Claim = %d, %v", n, err) |
| 173 | } |
| 174 | if err := s.Sync(ctx, d.sub(t, "sub_2"), abono.Hint{Subject: "D"}); err != nil { |
| 175 | t.Fatalf("Sync with D's hint: %v", err) |
| 176 | } |
| 177 | if got := db.dump(`SELECT subject FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_2'`); got != "C\n" { |
| 178 | t.Errorf("a claimed subscription went to %q on a later hint, want C", got) |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | // TestAdoptBringsEveryoneOnThePrice is final review I11 (ruling R40): the |
| 183 | // page promises that adopting one subscription puts everyone paying that |
| 184 | // price onto the plan. Two holders are recorded on a price no plan has; |
| 185 | // adopting one makes both members at once, each told through OnChange, |
| 186 | // before Adopt returns. |
| 187 | func TestAdoptBringsEveryoneOnThePrice(t *testing.T) { |
| 188 | now := t0 |
| 189 | d := newStripeDouble(t) |
| 190 | var mu sync.Mutex |
| 191 | var told []string |
| 192 | s, db := d.service(t, &now, abono.Config{ |
| 193 | Holder: func(_ context.Context, _ *gorm.DB, email, _ string) (string, error) { |
| 194 | return strings.TrimSuffix(email, "@example.com"), nil |
| 195 | }, |
| 196 | OnChange: func(_ context.Context, _ *gorm.DB, subject string) error { |
| 197 | mu.Lock() |
| 198 | defer mu.Unlock() |
| 199 | told = append(told, subject) |
| 200 | return nil |
| 201 | }, |
| 202 | }) |
| 203 | seedPlans(t, s) |
| 204 | ctx := context.Background() |
| 205 | for _, sp := range []subSpec{ |
| 206 | {ID: "sub_z1", Status: "active", Price: "price_Z", Customer: "cus_1", Email: "one@example.com", End: t0.Add(30 * day)}, |
| 207 | {ID: "sub_z2", Status: "active", Price: "price_Z", Customer: "cus_2", Email: "two@example.com", End: t0.Add(30 * day)}, |
| 208 | } { |
| 209 | if err := s.Sync(ctx, d.put(t, sp), abono.Hint{}); err != nil { |
| 210 | t.Fatalf("Sync %s: %v", sp.ID, err) |
| 211 | } |
| 212 | } |
| 213 | if un, err := s.Unrecognised(ctx); err != nil || len(un) != 2 { |
| 214 | t.Fatalf("Unrecognised = %+v, %v; want both", un, err) |
| 215 | } |
| 216 | |
| 217 | if err := s.Adopt(ctx, "sub_z1", "", "monthly"); err != nil { |
| 218 | t.Fatalf("Adopt: %v", err) |
| 219 | } |
| 220 | if got := db.dump(`SELECT stripe_subscription_id, subject, plan FROM abono_subscriptions ORDER BY stripe_subscription_id`); got != "sub_z1|one|monthly\nsub_z2|two|monthly\n" { |
| 221 | t.Errorf("rows after Adopt =\n%s\nwant both on monthly, each with its holder", got) |
| 222 | } |
| 223 | for _, who := range []string{"one", "two"} { |
| 224 | if !hasPlan(t, s, who, "monthly") { |
| 225 | t.Errorf("%s does not hold monthly after the adoption", who) |
| 226 | } |
| 227 | } |
| 228 | mu.Lock() |
| 229 | sort.Strings(told) |
| 230 | got := strings.Join(told, ",") |
| 231 | mu.Unlock() |
| 232 | if got != "one,two" { |
| 233 | t.Errorf("OnChange told %q, want one and two", got) |
| 234 | } |
| 235 | if un, err := s.Unrecognised(ctx); err != nil || len(un) != 0 { |
| 236 | t.Errorf("Unrecognised after Adopt = %+v, %v; want none", un, err) |
| 237 | } |
| 238 | } |
| 239 | |