Unit Test Documentation — Multiple Currency in Membership Plans¶
Branch: Multiple-currency-in-Membership-plans
Test project: Raklet.UnitTests
Related design: multiple-currency-in-membership-plans.md
Related test plan: multiple-currency-in-membership-plans-test-plan.md
This file documents the DB-independent unit test suite added for the multi-currency feature: which production code each file targets, which business rules it enforces, and — explicitly — which scenarios it does NOT cover (gaps that require integration / E2E coverage).
Snapshot¶
| Metric | Value |
|---|---|
| Test files added on this branch | 10 |
| Total tests added on this branch | 166 (run Raklet.UnitTests.dll filtered to the 10 classes below) |
| Last targeted run | ResolveSingleApplicationCurrencyTests: 18 / 18 passing |
| Test framework | MSTest 3.1.1, .NET Framework 4.7.2 |
| External dependencies | None — all DB-independent |
Run subset:
vstest.console.exe Raklet.UnitTests/bin/Debug/Raklet.UnitTests.dll \
/TestCaseFilter:"FullyQualifiedName~PlanDtoTests|FullyQualifiedName~DoesTokenMatchPaymentAccountTests|FullyQualifiedName~ResolveSingleApplicationCurrencyTests|FullyQualifiedName~MultipleCurrencyPlanGuardsTests|FullyQualifiedName~ScheduledPaymentTokenMatchTests|FullyQualifiedName~DeferMembershipFeeOrgTests|FullyQualifiedName~CustomMemberTypeTests|FullyQualifiedName~PaymentAccountsDtoTests|FullyQualifiedName~CheckoutServiceTests|FullyQualifiedName~DebtCurrencyResolutionTests"
Test files (one row per file)¶
| # | File | Production target | Rules | Tests | Strategy |
|---|---|---|---|---|---|
| 1 | CustomMemberTypeTests.cs | CustomMemberType.CalculatedPaymentAccountId | R3 | 9 | Direct (public computed property) |
| 2 | PaymentAccountsDtoTests.cs | PaymentAccountsDto.Map / IsSinglePaymentAccount | R5 (PA listing) | 12 | Direct (public static factory) |
| 3 | CheckoutServiceTests.cs | CheckoutService.FindMatching* | R5 (subset-sum reconciliation) | 35 | Direct (public static helpers) |
| 4 | PlanDtoTests.cs | PlanDto.Map | R8.3 | 15 | Direct (public static factory) |
| 5 | DoesTokenMatchPaymentAccountTests.cs | PaymentController.DoesTokenMatchPaymentAccount | R5.2 | 11 | Reflection (private static) |
| 6 | ResolveSingleApplicationCurrencyTests.cs | ApplicationFormService.ResolveSingleApplicationCurrency | R4.2 | 18 | Reflection (private static) |
| 7 | MultipleCurrencyPlanGuardsTests.cs | V2MembershipController helpers + R1/R2 guard combinations | R1.2, R1.4, R2.1, R2.2 | 29 | Mirror (Raklet.Api not referenced) |
| 8 | ScheduledPaymentTokenMatchTests.cs | V2PaymentController.IsScheduledPaymentTokenValidAsync | R6.1, R6.2 | 15 | Mirror (Raklet.Api not referenced) |
| 9 | DeferMembershipFeeOrgTests.cs | ApplicationFormService inline rule (Mumadoo) | R9.1, R9.4 | 14 | Mirror (inline rule, no method) |
| 10 | DebtCurrencyResolutionTests.cs | DebtCurrencyResolver.ResolveManualDebtCurrency + mirrored debt-currency orchestration coverage | R6.4 | 8 | Direct + Mirror (public static helper plus orchestration regression coverage) |
Strategy legend - Direct: the test calls the production method as-is (public surface). - Reflection: the production method is
private staticand lives in a referenced project; the test invokes it viaMethodInfo.Invokeso failures are detected against the real production code. - Mirror: the production code is in a project the test cannot reference (Raklet.Api), or is inlined inside a larger method, so the rule logic is duplicated verbatim inside the test class with a clear regression comment. Mirrors must be re-synced manually when production changes — the design + test plan documents both flag this maintenance cost.
Coverage by business rule¶
Sourced from the rules listed in multiple-currency-in-membership-plans.md. "Status" reflects what the unit test suite alone proves; integration / E2E gaps are flagged in the rightmost column.
| Rule | Description | Unit-test status | Test location | Integration / E2E gap |
|---|---|---|---|---|
| R1.1 | Plan create — Currency=null falls back to org.Currency |
NOT covered | — | Needs V2 controller integration test |
| R1.2 | Plan create — non-Stripe org rejects explicit PA | Covered (mirror) | MultipleCurrencyPlanGuardsTests.Reject_NonStripeOrg_WithExplicitPA_R1_2 |
Integration test should still verify the response shape |
| R1.3 | Plan create — empty PA stays null in DB | NOT covered | — | Needs DB integration test |
| R1.4 | Plan create — group sub-plan PA + currency parity | Covered (mirror) | MultipleCurrencyPlanGuardsTests.HasMatchingGroupMembershipPlanPA_* + Reject_GroupMembershipLink_* |
Integration test for full reject path with response message |
| R2.1 | Plan update — PA Stripe gate runs first | Covered (mirror) | MultipleCurrencyPlanGuardsTests.Reject_NonStripeOrg_WithExplicitPA_R1_2 (same predicate) |
Integration test for ordering vs R2.2 |
| R2.2 | Plan update — PA immutability | Covered (mirror) | MultipleCurrencyPlanGuardsTests.Reject_PAImmutability_NonEmptyDifferentValue_R2_2 + 3 allow cases |
DB integration test for "rejected request leaves DB untouched" |
| R2.3 | Plan update — Currency immutability | NOT covered | — | The check is a single-line guard inside UpdatePlan; needs integration test |
| R2.4 | Plan update — group sub-plans append-only when subscribed | NOT covered | — | Stateful: needs DB integration test |
| R2.5 | Plan update — group sub-plans free reset when unsubscribed | NOT covered | — | Stateful: needs DB integration test |
| R2.6 | Guards run before any DB writes | NOT covered (semantically) | — | DB integration test: failed PUT must not change plan row |
| R3.1 | CalculatedPaymentAccountId returns plan PA when set + Stripe-direct |
Covered | CustomMemberTypeTests.CalculatedPaymentAccountId_WhenPlanHasOwnPA_ReturnsPlanPA |
— |
| R3.2 | Falls back to Organisation.DefaultPaymentAccountId |
Covered | CustomMemberTypeTests.CalculatedPaymentAccountId_WhenPlanPAIsNull_ReturnsOrgDefault + _WhenPlanPAIsGuidEmpty_ReturnsOrgDefault |
— |
| R3.3 | Returns Guid.Empty when Organisation is null (no NRE) |
Covered | CustomMemberTypeTests.CalculatedPaymentAccountId_WhenOrganisationIsNull_ReturnsGuidEmpty + _WhenOrganisationIsNullAndPlanHasOwnPA_ReturnsPlanPA |
— |
| R3.4 | Service queries Include(Organisation) |
NOT covered | — | Static / code review or integration test |
| R3.x | Non-Stripe processor with stray plan PA still falls back to org default | Covered | CustomMemberTypeTests.CalculatedPaymentAccountId_WhenProcessorIsNotStripeWithDirectCharge_ReturnsOrgDefault |
— |
| R4.1 | Multi-plan approve — distinct PA hard-fail | NOT covered (predicate is inline DB query) | — | Integration test on ApproveApplication with multiple plans |
| R4.2 | ResolveSingleApplicationCurrency no-plan fallback, happy + sad paths |
Covered (reflection) | ResolveSingleApplicationCurrencyTests.* (18 tests) |
— |
| R4.3 | Approve sets Payment.Currency = plan.Currency and PaymentAccountId = plan.CalculatedPaymentAccountId |
NOT covered | — | Integration test on ApproveApplication with seeded plan |
| R4.4 | Sub-member payments stamped with plan PA + currency | NOT covered | — | Integration test for corporate-membership flow |
| R4.5 | CreditCardChargeRequest.Currency = resolvedCurrency.ToString() |
NOT covered | — | Integration / mock charger test |
| R4.6 | Token bound to PA after charge success | NOT covered | — | Integration test |
| R4.7 | Recurring loop guard (IsRecurring && token != null) \|\| isDeferOrg |
NOT covered | — | Integration test |
| R5.1 | Apply / Signup token stamped with resolved PA | NOT covered | — | Integration test |
| R5.2 | Saved-card selection rejects mismatched token | Covered (reflection) | DoesTokenMatchPaymentAccountTests.* (11 tests) |
— |
| R6.1 | IsScheduledPaymentTokenValidAsync predicate (post-DB) |
Covered (mirror) | ScheduledPaymentTokenMatchTests.* (15 tests) |
DB lookup branch (FindAsync returning null vs found) needs integration |
| R6.2 | Update scheduled — PA re-derived from plan | NOT covered (DB-bound) | — | Integration test |
| R6.3 | WebJob recurring iteration re-derives PA | NOT covered | — | Webjob integration test |
| R6.4 | Manual invoice/debt currency resolves from subscription plan, otherwise organisation currency | Covered (direct resolver + orchestration mirror) | DebtCurrencyResolutionTests.* (8 tests) |
Controller integration should still verify end-to-end controller/DTO wiring and default enum behavior |
| R7.1 | isMultipleCurrencyPlanPAEnabled() (AngularJS) |
NOT covered | — | Karma / manual |
| R7.2 | hasMatchingGroupMembershipPlanPA(plan) parity (AngularJS) |
NOT covered | — | Karma / manual; backend equivalent covered by MultipleCurrencyPlanGuardsTests.HasMatchingGroupMembershipPlanPA_* |
| R7.3 | AngularJS $watch clears mismatched sub-plans |
NOT covered | — | Manual / E2E |
| R7.4 | getFilteredPlans filters mismatched plans |
NOT covered | — | Manual / E2E |
| R7.5 | Edit mode disables Currency + PA | NOT covered | — | Manual / E2E |
| R7.6 | Insert mode default Currency | NOT covered | — | Manual / E2E |
| R8.1 | Member-facing views format with item.Currency |
NOT covered | — | Visual / manual |
| R8.2 | JsonController plan list uses plan.Currency |
NOT covered | — | Manual |
| R8.3 | PlanDto.Currency nullable, Map propagation |
Covered | PlanDtoTests.Map_Currency_* (5 tests) + Map_PaymentAccountId_* (3 tests) + round-trip (5 tests) |
— |
| R9.1 | isDeferMembershipFeeOrg flag (org set + time window) |
Covered (mirror) | DeferMembershipFeeOrgTests.* (14 tests) |
— |
| R9.2 | Defer-active path skips Payment, runs cleanup | NOT covered | — | Integration test on ApproveApplication with defer-org |
| R9.3 | Multi-currency code does not run on defer branch | NOT covered (orthogonality, not provable in unit) | — | Integration test |
| R9.4 | Cleanup TODO after 2026-06-01 |
Covered (constant pinned) | DeferMembershipFeeOrgTests.ExpiryDate_Is_2026_06_01_Utc_Midnight + boundary tests |
— |
Coverage tally¶
- Fully covered (unit only): R3.1, R3.2, R3.3, R3.x, R4.2, R5.2, R6.4, R8.3, R9.1, R9.4 → 10 rules
- Partial unit coverage (predicate / mirror; needs integration): R1.2, R1.4, R2.1, R2.2, R6.1 → 5 rules
- NOT covered by unit tests (integration / E2E only): R1.1, R1.3, R2.3–R2.6, R3.4, R4.1, R4.3–R4.7, R5.1, R6.2, R6.3, R7.1–R7.6, R8.1, R8.2, R9.2, R9.3 → 22 rules
The unit suite covers the pure decision logic (predicates, value-mapping, computed properties, time/set membership). It does not cover anything that requires a real
RakletDb, EF Core lifecycle, controller pipeline, AngularJS runtime, or WebJob infrastructure. Those gaps are explicitly enumerated above and tracked in the test plan.
What each test file handles (and what it does NOT)¶
1. CustomMemberTypeTests.cs (R3)¶
Handles:
- CalculatedPaymentAccountId returns plan PA when set on Stripe-direct org
- Falls back to org default when plan PA is null / Guid.Empty
- Returns Guid.Empty (no NRE) when Organisation navigation is null
- Returns plan PA when Organisation null but plan has explicit PA (lazy-load safety)
- Non-Stripe-direct processor → plan PA ignored, falls back to org default
- Determinism / repeated access stability
- Plan PA isolation from mid-test org-default mutations
Does NOT handle:
- DB read path (CustomMemberTypeService.FindAsync Include(Organisation)) — code-review / integration only
- Currency authority (R4) — covered separately in ResolveSingleApplicationCurrencyTests
2. PaymentAccountsDtoTests.cs¶
Handles:
- IsSinglePaymentAccount returns true for null / empty / 1-element list
- Returns false for 2+ accounts (multi-PA mode trigger)
- Map(null, ...) returns empty list (never null)
- Map projects all scalar fields (Id, Name, SubMerchantId, Processor)
- IsDefault flag set only on the account whose Id matches defaultPaymentAccountId
- Unknown defaultPaymentAccountId → all IsDefault=false
- IsSinglePaymentAccount correctly reflects mapped count
Does NOT handle:
- Filtering by Status == "Approved" — that's the caller's responsibility (PaymentAccountService)
- The selection of which PA list to expose to which controller — see PaymentAccountService.cs
3. CheckoutServiceTests.cs¶
Handles:
- FindMatchingIndices DP backtracking — null/empty/zero/negative inputs, single match, two-element, multi-element subset, no-match, exceeds-target
- FindMatchingInvoiceCombination — exact single match, two-invoice sum, three-invoice subset, null-item skipping, larger-than-target exclusion
- FindMatchingPaymentCombination — symmetric to invoice variant
- Cross-algorithm symmetry (invoice ↔ payment matching)
Does NOT handle:
- The actual _SortedUnpaidInvoices.cshtml reconciliation flow that calls these helpers
- Multi-currency grouping wrapped around the matching helpers — that lives inside the controller / view
4. PlanDtoTests.cs (R8.3)¶
Handles:
- Currency round-trip for TRY (=0), USD, EUR, GBP — TRY-as-zero distinguishable from "not set"
- Currency does not fall back to Organisation.Currency even when both are populated
- PaymentAccountId propagation: null, Guid.Empty, explicit Guid all preserved
- Map(entity) vs Map(entity, canBeDeleted) overloads
- CanThePlanBeDeleted flag round-trip
- Basic scalar field round-trip (Id, OrgId, Name, Fee, GracePeriod, Interval, FreePeriodInDays)
- GroupMembershipPlanIds populated from entity.GroupMembershipPlans with value=true
- Empty GroupMembershipPlans collection → empty dict
Does NOT handle:
- Reverse mapping (DTO → entity) — production code does field-by-field assignment in the controller, not a MapTo method
- Reminder template id transformation — orthogonal, not changed by this branch
- AngularJS form binding — covered by manual UI tests
5. DoesTokenMatchPaymentAccountTests.cs (R5.2)¶
Handles:
- Wildcard branches: paymentAccountId == null and paymentAccountId == Guid.Empty accept any token (or null token)
- Strict matching: token PA equals invoice PA → true
- Strict mismatch: token PA differs → false
- Token PA null or Guid.Empty cannot satisfy a strict PA constraint → false
- Null token + strict PA → false
- Determinism + per-token isolation across two PAs
Does NOT handle:
- Where DoesTokenMatchPaymentAccount is called from in PaymentController.cs — three call sites at lines 669, 836, 843. Those control flows are integration-tested.
- The selectedSavedCard branch logic that wraps it
- Token retrieval (_creditCardTokenService.FindAsync) — DB-bound
6. ResolveSingleApplicationCurrencyTests.cs (R4.2)¶
Handles:
- Null array and empty array return organisation.Currency for legacy no-plan application forms
- Null selected plan elements still throw InvalidOperationException
- Null-plan error messages mention the organisation id for diagnostics
- Single plan returns its currency (TRY, USD, EUR, GBP)
- Multiple plans agreeing on a single currency return that currency
- Mixed currencies throw with explicit "different currencies" message
- TRY (=0) edge case: not confused with "not set" — treated as a real currency, conflicts with USD as expected
Does NOT handle:
- The caller in ApproveApplication (lines ~416) that provides the array — integration-bound
- The PA-conflict guard at lines 392–410 (R4.1) — DB-bound, integration test
- Loading customMemberTypes from applicationForm.CustomMemberTypeIds — DB-bound
7. MultipleCurrencyPlanGuardsTests.cs (R1, R2)¶
Handles (mirror):
- SupportsMultipleCurrencyPlanPA(org) — null org → false; only StripeWithDirectCharge → true; Stripe / Iyzico / PayPal → false
- HasExplicitPaymentAccount(id) — null / Guid.Empty → false; valid Guid → true; all-ones Guid → true
- HasMatchingGroupMembershipPlanPA — non-Stripe org always true; null org always true; both null on Stripe-direct → true; same Guid → true; different / one-null on Stripe-direct → false; plain Stripe ignores parity
- Group-membership currency parity — mixed currencies reject on both Stripe-direct and non-Stripe orgs; matching PA + matching currency is allowed
- Combined R1.2 reject scenario: non-Stripe + explicit PA → reject
- Combined allow R1.2: non-Stripe + no PA → allow; Stripe-direct + explicit PA → allow
- Combined R2.2 reject: explicit PA + different from existing → reject
- Combined allow R2.2: same-PA round-trip → allow; null payload → allow; Guid.Empty payload → allow
Does NOT handle:
- The actual response shape (BadRequest("...") vs Ok(response) with errors) — Web API pipeline, integration test
- Order of guard execution vs DB writes (R2.6) — integration test
- The guards' interaction with the rest of UpdatePlan (group reconciliation in R2.4 / R2.5)
Mirror sync risk: if anyone edits V2MembershipController.cs:1359-1377, this test file will silently keep passing against the old logic. Re-sync the mirror block at the top of the test file when production helpers change.
8. ScheduledPaymentTokenMatchTests.cs (R6.1)¶
Handles (mirror, post-DB-lookup predicate):
- creditCardTokenId null / Guid.Empty → wildcard, always true
- Wildcard short-circuits before any membership / PA checks
- Token-not-found (loaded token null) → reject
- Token belongs to different OrganisationMembershipId → reject
- Token with OrganisationMembershipId == null → reject
- paymentAccountId null / Guid.Empty → PA wildcard, accept any token in the right membership
- Strict PA match passes
- Strict PA mismatch rejects (including token PA null / Guid.Empty vs strict invoice PA)
- Wrong-membership dominates over matching PA (membership check runs first)
- Determinism
Does NOT handle:
- The DB lookup itself (_creditCardTokenService.FindAsync(creditCardTokenId.Value)) — would need a service mock or integration test
- The R6.2 update path that re-derives resolvedPaymentAccountId from scheduledMembershipPlan.CalculatedPaymentAccountId — integration-bound
- The R6.3 WebJob recurring iteration — webjob test infrastructure required
Mirror sync risk: see comment in the file. The predicate is reproduced verbatim but stripped of the DB call.
9. DeferMembershipFeeOrgTests.cs (R9.1, R9.4)¶
Handles (mirror, inline rule from ApproveApplication):
- All 3 GUIDs (2dee3081-…, e2839ddf-…, 9aa955ff-…) recognised
- The set has exactly 3 entries (catches accidental add/remove)
- Guid.Empty and random GUIDs not in set
- Time window: before expiry → active when org matches
- Boundary: 1 tick before 2026-06-01 00:00:00 UTC → active
- Boundary: exactly at 2026-06-01 00:00:00 UTC → inactive (strict <, not <=)
- After expiry → inactive
- Far future → inactive
- Non-defer org + before expiry → still inactive (AND semantics)
- Expiry constant pinned to 2026-06-01 00:00:00 UTC with DateTimeKind.Utc
- Determinism over 100 evaluations (no side-effects on the set)
Does NOT handle:
- The payment-skip behaviour in ApproveApplication when the flag is true — integration test for: subscription set to Status=Scheduled, child memberships, debt removal, payment removal, IndexMembership
- The orthogonality claim that multi-currency code does not run on the defer branch (R9.3) — that's a structural property of the merged file, asserted via code review
- Any scheduling of the cleanup PR — that's a calendar reminder, not a test
Cleanup reminder: after
2026-06-01 UTC, thedeferMembershipFeeOrgIdsblock should be deleted from production. This test will then continue to pass against the mirror (the test mirror is the only thing left), making it a freestanding regression check that the GUIDs are no longer associated with deferred behaviour. Update or delete this test file as part of the R9.4 cleanup PR.
Maintenance notes¶
- Mirror drift is the biggest risk. Four of the ten new files (
MultipleCurrencyPlanGuardsTests,ScheduledPaymentTokenMatchTests,DeferMembershipFeeOrgTests,DebtCurrencyResolutionTests) duplicate small slabs of production logic. If anyone edits the production helpers or the inline defer rule without re-syncing, the tests will silently keep passing. The headers in each file flag this in bold; CI cannot detect drift. - Reflection-based tests will hard-fail at
[ClassInitialize]if the production method is renamed. This is intentional: the rename forces a deliberate update of both the test and the design doc rule citation. - Adding Raklet.Api as a project reference would let us reflect on V2 controller helpers directly (instead of mirroring them) — but it pulls the whole WebAPI graph into the test bin and risks dependency conflicts. Until that's worth doing, the mirror approach is the pragmatic compromise.
- All 167 tests run quickly on the dev box — they are safe to keep in the default test set.
- Test files must be registered in Raklet.UnitTests.csproj (
<Compile Include="..." />) — non-SDK csproj does not auto-include.csfiles. SeeCLAUDE.mdfor the project-wide rule.
Open follow-ups¶
- Integration test fixture for
V2MembershipControllerplan create / update — would close R1.1, R1.3, R2.3–R2.6 in one go. - Integration test for
ApproveApplicationwith seeded multi-plan / defer-org / mixed-currency cases — closes R4.1, R4.3–R4.7, R9.2, R9.3. - WebJob host for
ScheduledPaymentFunctions— closes R6.3 and the DB-bound branches of R6.1 / R6.2. - Karma / JS unit suite for
membership-plan-edit.controller.jsif the project ever wires one up — closes R7.1–R7.4. - Visual regression / snapshot tests for the six MVC views in R8.1 — currently manual.
- 2026-06-01 cleanup PR that removes the Mumadoo defer block from production. The mirror in
DeferMembershipFeeOrgTestsshould be deleted in the same PR (or kept as a documentation marker for the original rule).