From 739ecf53318ff8f467193b939e33db80b60ddcd2 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 10 Oct 2026 04:23:47 +0200 Subject: [PATCH] feat(insurance): export the supported contract-term list Moves the contract_term set into terms.go and adds ContractTerms(), a sorted copy, so consumers stop copying the 74 strings and drifting from what Comparison accepts. Closes #316 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ pkg/insurance/quote.go | 36 ---------------- pkg/insurance/terms.go | 51 +++++++++++++++++++++++ pkg/insurance/terms_test.go | 83 +++++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 36 deletions(-) create mode 100644 pkg/insurance/terms.go create mode 100644 pkg/insurance/terms_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 05c5ec9..6863d11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- **`insurance.ContractTerms()`:** returns the documented Archera + `contract_term` values that `Comparison` accepts and the decoder returns, as a + new sorted slice on every call (#316). The order is lexicographic, not by + duration. Consumers should call it instead of copying the list. - **`common.ErrOutcomeUnknown`:** exported sentinel for a purchase whose response was lost and whose commitment may exist (#301). Consumers can use `errors.Is` to tell it from a definite failure. The AWS EC2 and Redshift diff --git a/pkg/insurance/quote.go b/pkg/insurance/quote.go index a0727f9..9e92fad 100644 --- a/pkg/insurance/quote.go +++ b/pkg/insurance/quote.go @@ -518,42 +518,6 @@ func nullableTerm(s *string) (*string, error) { return s, nil } -// contractTerms is the documented contract_term enum, identical across -// OfferComparisonEntry.contract_term, HypotheticalTotal.contract_term and -// HypotheticalLineItem.actual_term. -var contractTerms = func() map[string]struct{} { - m := map[string]struct{}{} - for _, t := range []string{ - "one_year_gris", "thirty_day_gris", "two_month_gris", - "three_month_gris", "four_month_gris", "five_month_gris", - "six_month_gris", "seven_month_gris", "eight_month_gris", - "nine_month_gris", "ten_month_gris", "eleven_month_gris", - "twelve_month_gris", "thirteen_month_gris", "fourteen_month_gris", - "fifteen_month_gris", "sixteen_month_gris", "seventeen_month_gris", - "eighteen_month_gris", "nineteen_month_gris", "twenty_month_gris", - "twenty_one_month_gris", "twenty_two_month_gris", - "twenty_three_month_gris", "twenty_four_month_gris", - "twenty_five_month_gris", "twenty_six_month_gris", - "twenty_seven_month_gris", "twenty_eight_month_gris", - "twenty_nine_month_gris", "thirty_month_gris", "thirty_one_month_gris", - "thirty_two_month_gris", "thirty_three_month_gris", - "thirty_four_month_gris", "thirty_five_month_gris", "one_year", - "two_year", "three_year", "five_year", "zero_day", "thirty_day", - "two_month", "three_month", "four_month", "five_month", "six_month", - "seven_month", "eight_month", "nine_month", "ten_month", - "eleven_month", "thirteen_month", "fourteen_month", "fifteen_month", - "sixteen_month", "seventeen_month", "eighteen_month", "nineteen_month", - "twenty_month", "twenty_one_month", "twenty_two_month", - "twenty_three_month", "twenty_five_month", "twenty_six_month", - "twenty_seven_month", "twenty_eight_month", "twenty_nine_month", - "thirty_month", "thirty_one_month", "thirty_two_month", - "thirty_three_month", "thirty_four_month", "thirty_five_month", - } { - m[t] = struct{}{} - } - return m -}() - // numField is one JSON number to convert into *big.Rat. A required field that // is absent or null is an error; an optional one becomes nil (unknown). type numField struct { diff --git a/pkg/insurance/terms.go b/pkg/insurance/terms.go new file mode 100644 index 0000000..a7e16cb --- /dev/null +++ b/pkg/insurance/terms.go @@ -0,0 +1,51 @@ +package insurance + +import ( + "maps" + "slices" +) + +// contractTerms is the documented contract_term enum, identical across +// OfferComparisonEntry.contract_term, HypotheticalTotal.contract_term and +// HypotheticalLineItem.actual_term. +var contractTerms = func() map[string]struct{} { + m := map[string]struct{}{} + for _, t := range []string{ + "one_year_gris", "thirty_day_gris", "two_month_gris", + "three_month_gris", "four_month_gris", "five_month_gris", + "six_month_gris", "seven_month_gris", "eight_month_gris", + "nine_month_gris", "ten_month_gris", "eleven_month_gris", + "twelve_month_gris", "thirteen_month_gris", "fourteen_month_gris", + "fifteen_month_gris", "sixteen_month_gris", "seventeen_month_gris", + "eighteen_month_gris", "nineteen_month_gris", "twenty_month_gris", + "twenty_one_month_gris", "twenty_two_month_gris", + "twenty_three_month_gris", "twenty_four_month_gris", + "twenty_five_month_gris", "twenty_six_month_gris", + "twenty_seven_month_gris", "twenty_eight_month_gris", + "twenty_nine_month_gris", "thirty_month_gris", "thirty_one_month_gris", + "thirty_two_month_gris", "thirty_three_month_gris", + "thirty_four_month_gris", "thirty_five_month_gris", "one_year", + "two_year", "three_year", "five_year", "zero_day", "thirty_day", + "two_month", "three_month", "four_month", "five_month", "six_month", + "seven_month", "eight_month", "nine_month", "ten_month", + "eleven_month", "thirteen_month", "fourteen_month", "fifteen_month", + "sixteen_month", "seventeen_month", "eighteen_month", "nineteen_month", + "twenty_month", "twenty_one_month", "twenty_two_month", + "twenty_three_month", "twenty_five_month", "twenty_six_month", + "twenty_seven_month", "twenty_eight_month", "twenty_nine_month", + "thirty_month", "thirty_one_month", "thirty_two_month", + "thirty_three_month", "thirty_four_month", "thirty_five_month", + } { + m[t] = struct{}{} + } + return m +}() + +// ContractTerms returns the documented Archera contract_term values accepted +// in ComparisonRequest.ContractTerms and returned in the ContractTerm fields, +// as a new slice on every call. The order is lexicographic, which is NOT a +// duration order ("eleven_month" sorts before "five_month"); a consumer that +// shows the terms to people must impose its own ordering. +func ContractTerms() []string { + return slices.Sorted(maps.Keys(contractTerms)) +} diff --git a/pkg/insurance/terms_test.go b/pkg/insurance/terms_test.go new file mode 100644 index 0000000..568e4f2 --- /dev/null +++ b/pkg/insurance/terms_test.go @@ -0,0 +1,83 @@ +package insurance + +import ( + "context" + "net/http" + "slices" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// pinnedContractTerms is a second copy of the list in terms.go, not an +// independent check: no schema transcription or fixture exists in the repo +// (#285 added the code). It only catches an accidental edit of the source set. +// The absence of "twelve_month" and "twenty_four_month" is unverified against +// the live API. +var pinnedContractTerms = []string{ + "eight_month", "eight_month_gris", "eighteen_month", "eighteen_month_gris", + "eleven_month", "eleven_month_gris", "fifteen_month", "fifteen_month_gris", + "five_month", "five_month_gris", "five_year", "four_month", "four_month_gris", + "fourteen_month", "fourteen_month_gris", "nine_month", "nine_month_gris", + "nineteen_month", "nineteen_month_gris", "one_year", "one_year_gris", + "seven_month", "seven_month_gris", "seventeen_month", "seventeen_month_gris", + "six_month", "six_month_gris", "sixteen_month", "sixteen_month_gris", + "ten_month", "ten_month_gris", "thirteen_month", "thirteen_month_gris", + "thirty_day", "thirty_day_gris", "thirty_five_month", "thirty_five_month_gris", + "thirty_four_month", "thirty_four_month_gris", "thirty_month", "thirty_month_gris", + "thirty_one_month", "thirty_one_month_gris", "thirty_three_month", "thirty_three_month_gris", + "thirty_two_month", "thirty_two_month_gris", "three_month", "three_month_gris", + "three_year", "twelve_month_gris", "twenty_eight_month", "twenty_eight_month_gris", + "twenty_five_month", "twenty_five_month_gris", "twenty_four_month_gris", + "twenty_month", "twenty_month_gris", "twenty_nine_month", "twenty_nine_month_gris", + "twenty_one_month", "twenty_one_month_gris", "twenty_seven_month", "twenty_seven_month_gris", + "twenty_six_month", "twenty_six_month_gris", "twenty_three_month", "twenty_three_month_gris", + "twenty_two_month", "twenty_two_month_gris", "two_month", "two_month_gris", + "two_year", "zero_day", +} + +func TestContractTerms_PinnedSortedUnique(t *testing.T) { + got := ContractTerms() + assert.Len(t, got, 74) + assert.True(t, slices.IsSorted(got)) + assert.Equal(t, pinnedContractTerms, got) + assert.Equal(t, got, ContractTerms(), "deterministic across calls") +} + +func TestContractTerms_ReturnsACopy(t *testing.T) { + first := ContractTerms() + first[0] = "mutated" + first = append(first[:1], "extra") + _ = first + assert.Equal(t, pinnedContractTerms, ContractTerms()) +} + +// Every exported term is accepted by the decoder check and by the request +// validation; clearly invalid terms are rejected before any request. +func TestContractTerms_MatchValidators(t *testing.T) { + var calls atomic.Int32 + c := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(validComparisonJSON())) + })) + for _, term := range ContractTerms() { + term := term + _, err := nullableTerm(&term) + require.NoError(t, err, term) + _, err = c.Comparison(context.Background(), ComparisonRequest{PlanID: testPlan, ContractTerms: []string{term}}) + require.NoError(t, err, term) + } + assert.EqualValues(t, len(ContractTerms()), calls.Load()) + + before := calls.Load() + for _, bad := range []string{"one_decade", "forever"} { + _, err := nullableTerm(&bad) + require.Error(t, err, bad) + _, err = c.Comparison(context.Background(), ComparisonRequest{PlanID: testPlan, ContractTerms: []string{bad}}) + require.Error(t, err, bad) + } + assert.Equal(t, before, calls.Load(), "invalid terms must not reach the server") +}