Skip to content

Commit c817ba7

Browse files
authored
test: improve e2e invoice diagnostics (#4598)
1 parent 1446650 commit c817ba7

5 files changed

Lines changed: 165 additions & 15 deletions

File tree

.agents/skills/e2e/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,8 @@ Kafka is in the path for ingestion. Don't assert the meter value immediately aft
241241

242242
Reference: `e2e/e2e_test.go` `TestIngest`.
243243

244+
For async billing flows, make timeout failures self-diagnosing. Log the created resource IDs that connect the test to service logs (customer, subscription, invoice, pending line, charge when available), and during the polling loop log the externally visible state as JSON, such as invoice list entries and customer charge statuses. The E2E CI job streams docker compose logs during each test phase and archives per-service docker logs before each compose teardown/restart; keep those artifacts broad enough that a failed async hop can be correlated from the test output to the relevant service log.
245+
244246
## Testing conventions
245247

246248
- **`require` vs `assert`**: `require` for fatal preconditions (no point continuing), `assert` for soft per-field checks. In table rows, use `assert.Equal(t, tc.expectedStatus, status, "%+v", problem)` for the status check so the subsequent body-shape assertion still fires and surfaces in the same failure. Reserve `require` for lifecycle tests where later steps depend on the earlier status being correct.

.github/workflows/ci.yaml

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -758,9 +758,18 @@ jobs:
758758
cat e2e/docker-compose.override.yaml
759759
760760
- name: Launch Docker Compose infra
761+
id: launch_e2e
761762
run: docker compose -f docker-compose.infra.yaml -f docker-compose.openmeter.yaml -f docker-compose.override.yaml up -d
762763
working-directory: e2e
763764

765+
- name: Start Docker Compose log stream for base tests
766+
if: always() && steps.launch_e2e.outcome != 'skipped'
767+
working-directory: e2e
768+
run: |
769+
mkdir -p artifacts/logs/docker-compose/base
770+
docker compose -f docker-compose.infra.yaml -f docker-compose.openmeter.yaml -f docker-compose.override.yaml logs --no-color --timestamps --follow > artifacts/logs/docker-compose/base/compose-follow.log 2>&1 &
771+
echo "$!" > artifacts/logs/docker-compose/base/compose-follow.pid
772+
764773
- name: Set up Nix
765774
uses: nixbuild/nix-quick-install-action@9f63be77f412a248c9d9a65a4c82cf066cdf8f0c # v35
766775
with:
@@ -803,31 +812,70 @@ jobs:
803812
docker ps
804813
805814
- name: Run base tests
815+
id: run_base_tests
806816
env:
807817
OPENMETER_ADDRESS: http://localhost:38888
808818
TZ: UTC
809819
run: |
810820
nix develop --impure .#ci -c make -C e2e test-base
811821
822+
- name: Capture Docker Compose logs after base tests
823+
if: always() && steps.launch_e2e.outcome != 'skipped'
824+
working-directory: e2e
825+
run: |
826+
mkdir -p artifacts/logs/docker-compose/base
827+
if [ -f artifacts/logs/docker-compose/base/compose-follow.pid ]; then
828+
kill "$(cat artifacts/logs/docker-compose/base/compose-follow.pid)" 2>/dev/null || true
829+
sleep 1
830+
fi
831+
docker compose -f docker-compose.infra.yaml -f docker-compose.openmeter.yaml -f docker-compose.override.yaml ps --all > artifacts/logs/docker-compose/base/compose-ps.txt 2>&1 || true
832+
for service in $(docker compose -f docker-compose.infra.yaml -f docker-compose.openmeter.yaml -f docker-compose.override.yaml config --services); do
833+
docker compose -f docker-compose.infra.yaml -f docker-compose.openmeter.yaml -f docker-compose.override.yaml logs --no-color --timestamps "$service" > "artifacts/logs/docker-compose/base/${service}.log" 2>&1 || true
834+
done
835+
812836
- name: Restart Docker Compose infra for credits-disabled tests
837+
id: restart_credits_disabled_e2e
813838
run: |
814839
docker compose -f docker-compose.infra.yaml -f docker-compose.openmeter.yaml -f docker-compose.override.yaml down -v
815840
docker compose -f docker-compose.infra.yaml -f docker-compose.openmeter.yaml -f docker-compose.override.yaml -f docker-compose.credits-disabled.yaml up -d
816841
working-directory: e2e
817842

843+
- name: Start Docker Compose log stream for credits-disabled tests
844+
if: always() && steps.restart_credits_disabled_e2e.outcome != 'skipped'
845+
working-directory: e2e
846+
run: |
847+
mkdir -p artifacts/logs/docker-compose/credits-disabled
848+
docker compose -f docker-compose.infra.yaml -f docker-compose.openmeter.yaml -f docker-compose.override.yaml -f docker-compose.credits-disabled.yaml logs --no-color --timestamps --follow > artifacts/logs/docker-compose/credits-disabled/compose-follow.log 2>&1 &
849+
echo "$!" > artifacts/logs/docker-compose/credits-disabled/compose-follow.pid
850+
818851
- name: Wait for worker to become ready with credits disabled
819852
run: |
820853
curl --fail --retry 10 --retry-max-time 120 --retry-all-errors http://localhost:30000/healthz
821854
curl --fail --retry 10 --retry-max-time 120 --retry-all-errors http://localhost:30001/healthz
822855
docker ps
823856
824857
- name: Run credits-disabled tests
858+
id: run_credits_disabled_tests
825859
env:
826860
OPENMETER_ADDRESS: http://localhost:38888
827861
TZ: UTC
828862
run: |
829863
nix develop --impure .#ci -c make -C e2e test-credits-disabled
830864
865+
- name: Capture Docker Compose logs after credits-disabled tests
866+
if: always() && steps.restart_credits_disabled_e2e.outcome != 'skipped'
867+
working-directory: e2e
868+
run: |
869+
mkdir -p artifacts/logs/docker-compose/credits-disabled
870+
if [ -f artifacts/logs/docker-compose/credits-disabled/compose-follow.pid ]; then
871+
kill "$(cat artifacts/logs/docker-compose/credits-disabled/compose-follow.pid)" 2>/dev/null || true
872+
sleep 1
873+
fi
874+
docker compose -f docker-compose.infra.yaml -f docker-compose.openmeter.yaml -f docker-compose.override.yaml -f docker-compose.credits-disabled.yaml ps --all > artifacts/logs/docker-compose/credits-disabled/compose-ps.txt 2>&1 || true
875+
for service in $(docker compose -f docker-compose.infra.yaml -f docker-compose.openmeter.yaml -f docker-compose.override.yaml -f docker-compose.credits-disabled.yaml config --services); do
876+
docker compose -f docker-compose.infra.yaml -f docker-compose.openmeter.yaml -f docker-compose.override.yaml -f docker-compose.credits-disabled.yaml logs --no-color --timestamps "$service" > "artifacts/logs/docker-compose/credits-disabled/${service}.log" 2>&1 || true
877+
done
878+
831879
- name: Cleanup Docker Compose
832880
run: docker compose -f docker-compose.infra.yaml -f docker-compose.openmeter.yaml -f docker-compose.override.yaml down -v
833881
working-directory: e2e
@@ -839,6 +887,6 @@ jobs:
839887
with:
840888
name: "[${{ github.job }}] Openmeter logs"
841889
path: |
842-
e2e/logs/openmeter/openmeter.log
843-
e2e/logs/sink-worker/openmeter.log
890+
e2e/logs/**
891+
e2e/artifacts/logs/**
844892
retention-days: 14

e2e/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
logs
2+
artifacts
23
docker-compose.override.yaml

e2e/billinginvoices_v3_test.go

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package e2e
22

33
import (
4+
"encoding/json"
45
"net/http"
56
"slices"
7+
"strconv"
68
"testing"
79
"time"
810

@@ -188,21 +190,39 @@ func TestV3GetBillingInvoice(t *testing.T) {
188190
require.Equal(t, http.StatusCreated, lineResp.StatusCode(), "line: %s", string(lineResp.Body))
189191
require.NotNil(t, lineResp.JSON201)
190192

193+
t.Logf("created standard pending invoice line: customer_id=%s invoice_id=%s lines=%s",
194+
customerID,
195+
lineResp.JSON201.Invoice.Id,
196+
formatInvoiceLinesForLog(lineResp.JSON201.Lines),
197+
)
198+
191199
ctx := t.Context()
192200
customers := api.InvoiceListParamsCustomers{customerID}
193-
assert.EventuallyWithT(t, func(c *assert.CollectT) {
201+
expand := api.InvoiceListParamsExpand{api.InvoiceExpandLines}
202+
pollAttempt := 0
203+
assert.EventuallyWithT(t, func(collect *assert.CollectT) {
204+
pollAttempt++
205+
194206
listResp, err := v1.ListInvoicesWithResponse(ctx, &api.ListInvoicesParams{
195207
Customers: &customers,
208+
Expand: &expand,
196209
PageSize: lo.ToPtr(api.PaginationPageSize(100)),
197210
})
198-
require.NoError(c, err)
199-
require.Equal(c, http.StatusOK, listResp.StatusCode(), "list invoices: %s", string(listResp.Body))
200-
require.NotNil(c, listResp.JSON200)
211+
require.NoError(collect, err)
212+
require.Equal(collect, http.StatusOK, listResp.StatusCode(), "list invoices: %s", string(listResp.Body))
213+
require.NotNil(collect, listResp.JSON200)
214+
215+
chargeStatus, charges, problem, chargeErr := c.ListCustomerChargesForDiagnostics(ctx, customerID, withPageSize(100))
216+
t.Logf("standard invoice poll %02d: invoices=%s charges=%s",
217+
pollAttempt,
218+
formatInvoicesForLog(listResp.JSON200.Items),
219+
formatChargesForLog(chargeStatus, charges, problem, chargeErr),
220+
)
201221

202222
standardInvoiceIdx := slices.IndexFunc(listResp.JSON200.Items, func(inv api.Invoice) bool {
203223
return inv.Status != api.InvoiceStatusGathering
204224
})
205-
require.NotEqual(c, -1, standardInvoiceIdx, "expected charges to advance a pending line into a standard invoice")
225+
require.NotEqual(collect, -1, standardInvoiceIdx, "expected charges to advance a pending line into a standard invoice")
206226

207227
invoiceID = listResp.JSON200.Items[standardInvoiceIdx].Id
208228
}, time.Minute, time.Second)
@@ -263,3 +283,44 @@ func TestV3GetBillingInvoice(t *testing.T) {
263283
assert.NotNil(t, problem)
264284
})
265285
}
286+
287+
func formatInvoicesForLog(invoices []api.Invoice) string {
288+
return formatLogJSON(invoices)
289+
}
290+
291+
func formatInvoiceLinesForLog(lines []api.InvoiceLine) string {
292+
return formatLogJSON(lines)
293+
}
294+
295+
func formatChargesForLog(status int, charges *apiv3.ChargePagePaginatedResponse, problem *v3Problem, err error) string {
296+
if err != nil {
297+
return formatLogJSON(struct {
298+
Status int `json:"status"`
299+
Error string `json:"error"`
300+
}{
301+
Status: status,
302+
Error: err.Error(),
303+
})
304+
}
305+
306+
if status != http.StatusOK || charges == nil {
307+
return formatLogJSON(struct {
308+
Status int `json:"status"`
309+
Problem *v3Problem `json:"problem,omitempty"`
310+
}{
311+
Status: status,
312+
Problem: problem,
313+
})
314+
}
315+
316+
return formatLogJSON(charges)
317+
}
318+
319+
func formatLogJSON(v any) string {
320+
out, err := json.Marshal(v)
321+
if err != nil {
322+
return `{"marshal_error":` + strconv.Quote(err.Error()) + `}`
323+
}
324+
325+
return string(out)
326+
}

e2e/v3helpers_test.go

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,28 +115,45 @@ func (p *v3Problem) ValidationErrors() []v3ValidationError {
115115
func (c *v3Client) do(method, path string, body any) (int, []byte, *v3Problem) {
116116
c.t.Helper()
117117

118+
ctx, cancel := context.WithTimeout(c.t.Context(), v3RequestTimeout)
119+
defer cancel()
120+
121+
status, raw, problem, err := c.doRequest(ctx, method, path, body)
122+
require.NoError(c.t, err, "%s %s", method, path)
123+
124+
return status, raw, problem
125+
}
126+
127+
func (c *v3Client) doRequest(ctx context.Context, method, path string, body any) (int, []byte, *v3Problem, error) {
118128
var bodyReader io.Reader
119129
if body != nil {
120130
b, err := json.Marshal(body)
121-
require.NoError(c.t, err)
131+
if err != nil {
132+
return 0, nil, nil, fmt.Errorf("marshal request body: %w", err)
133+
}
134+
122135
bodyReader = bytes.NewReader(b)
123136
}
124137

125-
ctx, cancel := context.WithTimeout(c.t.Context(), v3RequestTimeout)
126-
defer cancel()
127-
128138
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
129-
require.NoError(c.t, err)
139+
if err != nil {
140+
return 0, nil, nil, fmt.Errorf("create request: %w", err)
141+
}
142+
130143
if body != nil {
131144
req.Header.Set("Content-Type", "application/json")
132145
}
133146

134147
resp, err := http.DefaultClient.Do(req)
135-
require.NoError(c.t, err, "%s %s", method, path)
148+
if err != nil {
149+
return 0, nil, nil, fmt.Errorf("do request: %w", err)
150+
}
136151
defer resp.Body.Close()
137152

138153
raw, err := io.ReadAll(resp.Body)
139-
require.NoError(c.t, err)
154+
if err != nil {
155+
return resp.StatusCode, nil, nil, fmt.Errorf("read response body: %w", err)
156+
}
140157

141158
var problem *v3Problem
142159
if resp.StatusCode >= 400 && len(raw) > 0 {
@@ -146,7 +163,7 @@ func (c *v3Client) do(method, path string, body any) (int, []byte, *v3Problem) {
146163
}
147164
}
148165

149-
return resp.StatusCode, raw, problem
166+
return resp.StatusCode, raw, problem, nil
150167
}
151168

152169
// decodeTyped is a shared helper used by the typed wrappers below. It returns
@@ -303,6 +320,27 @@ func (c *v3Client) ListCustomerCharges(customerID string, opts ...listOption) (i
303320
return decodeTyped[apiv3.ChargePagePaginatedResponse](c, status, raw, problem, http.StatusOK)
304321
}
305322

323+
func (c *v3Client) ListCustomerChargesForDiagnostics(ctx context.Context, customerID string, opts ...listOption) (int, *apiv3.ChargePagePaginatedResponse, *v3Problem, error) {
324+
ctx, cancel := context.WithTimeout(ctx, v3RequestTimeout)
325+
defer cancel()
326+
327+
status, raw, problem, err := c.doRequest(ctx, http.MethodGet, "/customers/"+customerID+"/charges"+buildPageQuery(opts), nil)
328+
if err != nil {
329+
return status, nil, nil, err
330+
}
331+
332+
if status != http.StatusOK {
333+
return status, nil, problem, nil
334+
}
335+
336+
var charges apiv3.ChargePagePaginatedResponse
337+
if err := json.Unmarshal(raw, &charges); err != nil {
338+
return status, nil, nil, fmt.Errorf("decode response: %w", err)
339+
}
340+
341+
return status, &charges, nil, nil
342+
}
343+
306344
func (c *v3Client) ListCustomerChargesByStatus(customerID string, statuses []apiv3.BillingChargeStatus, opts ...listOption) (int, *apiv3.ChargePagePaginatedResponse, *v3Problem) {
307345
vals := buildPageValues(opts)
308346
if len(statuses) > 0 {

0 commit comments

Comments
 (0)