Skip to content

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 static and lives in a referenced project; the test invokes it via MethodInfo.Invoke so 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, the deferMembershipFeeOrgIds block 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

  1. 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.
  2. 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.
  3. 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.
  4. All 167 tests run quickly on the dev box — they are safe to keep in the default test set.
  5. Test files must be registered in Raklet.UnitTests.csproj (<Compile Include="..." />) — non-SDK csproj does not auto-include .cs files. See CLAUDE.md for the project-wide rule.

Open follow-ups

  1. Integration test fixture for V2MembershipController plan create / update — would close R1.1, R1.3, R2.3–R2.6 in one go.
  2. Integration test for ApproveApplication with seeded multi-plan / defer-org / mixed-currency cases — closes R4.1, R4.3–R4.7, R9.2, R9.3.
  3. WebJob host for ScheduledPaymentFunctions — closes R6.3 and the DB-bound branches of R6.1 / R6.2.
  4. Karma / JS unit suite for membership-plan-edit.controller.js if the project ever wires one up — closes R7.1–R7.4.
  5. Visual regression / snapshot tests for the six MVC views in R8.1 — currently manual.
  6. 2026-06-01 cleanup PR that removes the Mumadoo defer block from production. The mirror in DeferMembershipFeeOrgTests should be deleted in the same PR (or kept as a documentation marker for the original rule).