Compare commits
21 Commits
feat/go-m5
...
2f635db2b4
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f635db2b4 | |||
| 709a2f2335 | |||
| 58973473c9 | |||
| b68d95d217 | |||
| 07ca1cd9e1 | |||
| 5dcac25c13 | |||
| fc47606b1c | |||
| 65694ad378 | |||
| 092dff25a5 | |||
| 56c21bcf03 | |||
| 208f762c18 | |||
| 4d035213b5 | |||
| 2b15280d03 | |||
| 723152cdad | |||
| fe0e49a134 | |||
| e5a272b682 | |||
| 8b3064ffab | |||
| 423c3e2a4b | |||
| f4c497681f | |||
| 40e4a9e45e | |||
| 68810369bd |
41
CHANGELOG.md
41
CHANGELOG.md
@@ -1,5 +1,46 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-05-08 00:26 CEST — fix(py): parity coercions — amount/message types + junior '?' sticky
|
||||
|
||||
- `scripts/match_payments.py`: added `get_float` helper — non-numeric `amount` values (e.g. `"---"` placeholder rows) now coerce to `0.0` matching Go's `parseFloat` behaviour; `message` field now goes through `get_str` so numeric cell values (bank references) are emitted as strings, matching Go's `fmt.Sprint`.
|
||||
- `scripts/views.py`: junior month cell `"?"` text is now sticky across exception overrides. Previously `reconcile` replaced `expected` with the exception amount before the view builder ran, silently turning `"?"` into `"-"` when the override was 0. Fixed by deriving `is_unknown` from `original_expected == "?"` instead of `expected == "?"`. Also aligned tooltip guard: only show Received/Expected for non-unknown months (or when paid > 0), matching Go's `!md.IsUnknown` condition.
|
||||
|
||||
## 2026-05-07 23:51 CEST — feat(py): M5.4 fix #2 — add vs and sync_id to payments tx projection
|
||||
|
||||
- `scripts/match_payments.py`: `fetch_sheet_data` now reads `VS` and `Sync ID` columns and includes `vs`/`sync_id` keys in every tx dict. Previously only 9 columns were projected, causing `make parity` to report extra `vs`/`sync_id` fields on every raw payment row emitted by the Go backend. Values flow through `group_payments_by_person` → `_unwrap_view_model_for_api` to `raw_payments` (adults/juniors) and `grouped_payments` (payments) automatically.
|
||||
- `tests/test_app.py`: updated `/api/*` mock fixtures to include `vs`/`sync_id` keys for realism.
|
||||
- **Cache note**: after deploying, hit `POST /flush-cache` once so the in-process cache is cleared and the next request picks up the new column lookups.
|
||||
|
||||
## 2026-05-07 23:37 CEST — fix(go): accept single-digit day/month in attendance date headers
|
||||
|
||||
- `go/internal/services/membership/sources.go`: `parseDates` now uses Go time formats `2.1.2006` and `1/2/2006` (single-digit reference forms, which accept both padded and unpadded inputs) instead of `02.01.2006` and `01/02/2006`. The Czech attendance sheet headers contain dates like `1.6.2026`, `23.3.2026`, `6.4.2026` — Go silently dropped those columns under the strict zero-padded format, while Python's `strptime("%d.%m.%Y")` accepted them. Effect was a missing `2026-06` month entirely on `/api/juniors` plus undercounted attendance for any month with single-digit columns; both surfaced as diffs in `make parity`.
|
||||
- `sources_test.go::TestParseDates_SingleDigitDayMonth` added as a regression guard covering both Czech and US format flavours with and without leading zeros.
|
||||
|
||||
## 2026-05-07 23:17 CEST — fix(go): pass raw value to FormatDate so numeric serial-day dates format
|
||||
|
||||
- `go/internal/services/membership/sources.go`: transaction-row parser now passes `row[idxDate]` directly to `matching.FormatDate` (via a new `getRaw` helper) instead of stringifying first via `getVal`. The Sheets API returns numeric serial-day values as `float64` for date-formatted cells; pre-stringifying them defeated `FormatDate`'s `case float64:` dispatch, causing all numeric dates to leak through as `"46147"` style strings instead of `"2026-05-05"`.
|
||||
- Surfaced by `make parity` (M5.4): every `transactions[].date` field on `/api/adults` and `/api/juniors` differed between Python and Go.
|
||||
- `sources_test.go::TestLoadTransactions` extended with a numeric-serial-day row covering the regression.
|
||||
|
||||
## 2026-05-07 23:05 CEST — fix(go): default CacheDir to `tmp/go` to avoid Python collision
|
||||
|
||||
- `go/internal/config/config.go`: `CacheDir` default changed from `tmp` to `tmp/go`. Override via `CACHE_DIR` env var still works.
|
||||
- Why: both backends used `tmp/<key>_cache.json` with the same keys (`attendance_regular`, `attendance_juniors`, `payments_transactions`, `exceptions_dict`) but different shapes — Python caches post-processed view-model tuples, Go caches raw rows. Whichever wrote last poisoned the cache; running both in parallel produced `ValueError: too many values to unpack (expected 2, got 68)` on Python's `/adults` after the Go server populated `attendance_regular_cache.json` with raw CSV rows.
|
||||
- After upgrading: stop the Go server, hit `/flush-cache` on the Python side once (rewrites `tmp/*.json` with correct shapes), then restart `make web-go` — it will use `tmp/go/` going forward. Required for the M5.4 `make parity` workflow which assumes both backends run side-by-side.
|
||||
|
||||
## 2026-05-07 22:55 CEST — feat(go): M5.4 — parity diff binary + `make parity`
|
||||
|
||||
- `go/cmd/parity/main.go`: new standalone binary that GETs `/api/adults`, `/api/juniors`, `/api/payments` from both Python (:5001) and Go (:8080) backends, scrubs an allowlist (`render_time.total`), and prints `cmp.Diff` for any remaining differences. Exits 0 on full match, 1 on diffs, 2 on fetch/parse errors — CI-friendly for M7.2. `/api/version` is excluded by design (returns binary identity — tag/commit/build_date — which differs between independently built backends); still accessible via `make parity ARGS="-route /api/version"`.
|
||||
- `go/cmd/parity/scrub_test.go`: 4 unit tests covering top-level delete, nested delete, missing path, and non-map parent.
|
||||
- `go/go.mod`: `github.com/google/go-cmp` promoted to direct dependency.
|
||||
- `Makefile`: `parity` target added (`.PHONY`, help, `cd go && go run ./cmd/parity`).
|
||||
- `docs/plans/2026-05-07-2254-m5-4-parity-binary.md`: plan archived.
|
||||
|
||||
## 2026-05-07 22:37 CEST — feat(py): M5.3 — Python /api/* shadow endpoints
|
||||
|
||||
- `app.py`: four new JSON routes (`/api/version`, `/api/adults`, `/api/juniors`, `/api/payments`) mirroring the Go `/api/*` handlers; `_unwrap_view_model_for_api()` helper expands pre-serialised JSON strings and renames `month_labels_json` → `month_labels`, `raw_payments_json` → `raw_payments` to match Go wire contract.
|
||||
- `tests/test_app.py`: four new smoke tests asserting top-level key sets and that unwrapped fields are objects (not strings).
|
||||
|
||||
## 2026-05-07 20:13 CEST — feat(go): M5.2 — HTTP handlers for /api/adults, /api/juniors, /api/payments, /api/version
|
||||
|
||||
- `web/api/handler.go`: `Handler` struct + `ServeAdults`, `ServeJuniors`, `ServePayments`, `ServeVersion` using `membership.Sources`.
|
||||
|
||||
6
Makefile
6
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures image run sync sync-2026 test test-v docs
|
||||
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures parity image run sync sync-2026 test test-v docs
|
||||
|
||||
export PYTHONPATH := scripts:$(PYTHONPATH)
|
||||
VENV := .venv
|
||||
@@ -29,6 +29,7 @@ help:
|
||||
@echo " make go-lint - Run golangci-lint on Go code"
|
||||
@echo " make go-sync-debug [DAYS=N] - Dry-run Go sync with Fio debug logs and txn table (default DAYS=30)"
|
||||
@echo " make capture-fixtures - Regenerate parity fixture corpus from live Python"
|
||||
@echo " make parity - Diff /api/* between web-py (:5001) and web-go (:8080); both must be running"
|
||||
@echo " make image - Build Python OCI container image"
|
||||
@echo " make run - Run the built Python Docker image locally"
|
||||
@echo " make sync - Sync Fio transactions to Google Sheets"
|
||||
@@ -102,6 +103,9 @@ go-lint:
|
||||
web-go: go-build
|
||||
./$(GO_BIN) server
|
||||
|
||||
parity:
|
||||
cd $(GO_SRC) && go run ./cmd/parity $(ARGS)
|
||||
|
||||
image:
|
||||
docker build -t fuj-management:latest \
|
||||
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
|
||||
|
||||
81
app.py
81
app.py
@@ -7,7 +7,7 @@ import os
|
||||
import io
|
||||
import qrcode
|
||||
import logging
|
||||
from flask import Flask, render_template, g, send_file, request
|
||||
from flask import Flask, render_template, g, send_file, request, jsonify
|
||||
|
||||
# Configure logging, allowing override via LOG_LEVEL environment variable
|
||||
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||
@@ -68,6 +68,16 @@ BUILD_META = _json.loads(_meta_path.read_text()) if _meta_path.exists() else {
|
||||
"tag": "dev", "commit": "local", "build_date": ""
|
||||
}
|
||||
|
||||
|
||||
def _unwrap_view_model_for_api(vm: dict) -> dict:
|
||||
"""Expand pre-stringified JSON fields and rename to match Go API contract."""
|
||||
out = dict(vm)
|
||||
out["member_data"] = _json.loads(out.pop("member_data"))
|
||||
out["month_labels"] = _json.loads(out.pop("month_labels_json"))
|
||||
out["raw_payments"] = _json.loads(out.pop("raw_payments_json"))
|
||||
return out
|
||||
|
||||
|
||||
warmup_cache()
|
||||
|
||||
@app.before_request
|
||||
@@ -144,6 +154,75 @@ def sync_bank():
|
||||
def version():
|
||||
return BUILD_META
|
||||
|
||||
@app.route("/api/version")
|
||||
def api_version():
|
||||
return jsonify(BUILD_META)
|
||||
|
||||
@app.route("/api/adults")
|
||||
def api_adults():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||
if not members_data:
|
||||
return jsonify({"error": "no data"}), 503
|
||||
members, sorted_months = members_data
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
|
||||
exceptions = get_cached_data(
|
||||
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||
PAYMENTS_SHEET_ID, CREDENTIALS_PATH,
|
||||
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||
)
|
||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||
vm = build_adults_view_model(
|
||||
members, sorted_months, result, transactions,
|
||||
datetime.now().strftime("%Y-%m"),
|
||||
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
|
||||
)
|
||||
return jsonify(_unwrap_view_model_for_api(vm))
|
||||
|
||||
@app.route("/api/juniors")
|
||||
def api_juniors():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||
if not junior_members_data:
|
||||
return jsonify({"error": "no data"}), 503
|
||||
junior_members, sorted_months = junior_members_data
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
|
||||
exceptions = get_cached_data(
|
||||
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||
PAYMENTS_SHEET_ID, CREDENTIALS_PATH,
|
||||
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||
)
|
||||
adapted_members = adapt_junior_members(junior_members)
|
||||
result = reconcile(adapted_members, sorted_months, transactions, exceptions)
|
||||
vm = build_juniors_view_model(
|
||||
junior_members, adapted_members, sorted_months, result, transactions,
|
||||
datetime.now().strftime("%Y-%m"),
|
||||
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
|
||||
)
|
||||
return jsonify(_unwrap_view_model_for_api(vm))
|
||||
|
||||
@app.route("/api/payments")
|
||||
def api_payments():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
|
||||
adults_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||
juniors_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||
member_names = []
|
||||
if adults_data:
|
||||
member_names.extend(name for name, _, _ in adults_data[0])
|
||||
if juniors_data:
|
||||
member_names.extend(name for name, _, _ in juniors_data[0])
|
||||
vm = build_payments_view_model(
|
||||
transactions, member_names,
|
||||
attendance_url=attendance_url, payments_url=payments_url,
|
||||
)
|
||||
return jsonify(vm)
|
||||
|
||||
@app.route("/adults")
|
||||
def adults_view():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md).
|
||||
|
||||
**Current milestone:** M4 — IO layer behind interfaces ✅
|
||||
**Current milestone:** M5 — JSON-only `/api/...` routes ✅
|
||||
**Started:** 2026-05-04
|
||||
**Last updated:** 2026-05-07
|
||||
**Last updated:** 2026-05-07 (M5.4)
|
||||
|
||||
## How to use
|
||||
|
||||
@@ -99,8 +99,8 @@ Goal: byte-equal JSON between Python and Go for every route. This is the parity
|
||||
|
||||
- [x] **M5.1** Hand-author Go structs for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version` with explicit `json:` tags matching Python keys; emit JSON Schemas via `github.com/invopop/jsonschema` to `tests/fixtures/api-schema/` — `f253e3f`
|
||||
- [x] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs — `7d48e8f`
|
||||
- [ ] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation
|
||||
- [ ] **M5.4** Build `cmd/parity/main.go`: hits both backends' `/api/X`, normalizes allowlist (`render_time.total`, `build_meta`), prints `cmp.Diff`. Add `make parity` target
|
||||
- [x] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation — `40e4a9e`
|
||||
- [x] **M5.4** Build `cmd/parity/main.go`: hits both backends' `/api/X`, normalizes allowlist (`render_time.total`, `build_meta`), prints `cmp.Diff`. Add `make parity` target
|
||||
|
||||
**Gate:** For each route, `make parity` reports zero non-allowlisted diffs across the M3 fixture corpus.
|
||||
|
||||
@@ -154,6 +154,7 @@ Goal: Go is the one true backend.
|
||||
|
||||
(Add entries as you go. Format: `YYYY-MM-DD — short note`.)
|
||||
|
||||
- 2026-05-07 — `/api/version` excluded from parity diff by design. Each binary's tag/commit/build_date is identity, not a data contract — diffing it would always flag a diff between independently built backends. Route remains reachable via `make parity ARGS="-route /api/version"` for manual inspection.
|
||||
- 2026-05-04 — Plan approved. Versioning policy: latest stable for Go and all libs at the time M1 starts. Frontends explicitly allowed to diverge between Python and Go; only the JSON API contract is parity-locked. No reverse proxy — both backends run on different ports via `make web-py` / `make web-go`.
|
||||
- 2026-05-07 — M4 complete. Chose fakes-only unit tests (no live integration tests) and CSV-via-public-URL for attendance (no Sheets API auth required for read-only). golangci-lint gofumpt extra-rules differ slightly from standalone gofumpt; used `golangci-lint run --fix --enable-only gofumpt` to auto-resolve formatting.
|
||||
- 2026-05-04 — M1 complete. Dockerfile base changed from `distroless/static:nonroot` → `alpine:3` for debuggability (can tighten later). CLI dispatcher uses stdlib `flag`; module path `fuj-management/go`. golangci-lint v1 embedded gofumpt merges all imports into one group (no stdlib/local split) — accepted as the project style.
|
||||
|
||||
113
docs/plans/2026-05-07-2114-go-rewrite-m5-3-python-api-shadow.md
Normal file
113
docs/plans/2026-05-07-2114-go-rewrite-m5-3-python-api-shadow.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# M5.3 — Python `/api/X` shadow endpoints
|
||||
|
||||
Companion to:
|
||||
- [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) (master design)
|
||||
- [2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) (M5.3 row)
|
||||
- [2026-05-07-1431-m5-json-api-parity.md](2026-05-07-1431-m5-json-api-parity.md) (Python view-model extraction prep)
|
||||
- [2026-05-07-1650-go-rewrite-m5-1-api-structs-schemas.md](2026-05-07-1650-go-rewrite-m5-1-api-structs-schemas.md) (Go wire types)
|
||||
|
||||
## Context
|
||||
|
||||
M5.1 (Go wire types + JSON Schemas) and M5.2 (Go HTTP handlers for `/api/adults` `/api/juniors` `/api/payments` `/api/version`) have merged. M5.3 mirrors the same four endpoints on the Python Flask side so M5.4's `cmd/parity` tool can hit both backends and diff the JSON. After M5.3, every byte the Go side emits has a Python counterpart to compare against.
|
||||
|
||||
The Python view-model builders ([scripts/views.py](scripts/views.py)) already produce dicts very close to the wire shape — except three template-only fields (`member_data`, `month_labels_json`, `raw_payments_json`) are pre-`json.dumps`'d for inline `<script>` blocks. M5.1's plan called this out explicitly: M5.3's `/api/X` is `jsonify(unwrap_json_strings(view_model_dict))` — a 4-line shim, not real transformation logic.
|
||||
|
||||
## Approach
|
||||
|
||||
Add four shadow routes to [app.py](app.py) and one private unwrap helper. Builders and templates are untouched.
|
||||
|
||||
### Decisions
|
||||
|
||||
1. **Unwrap shim lives in `app.py`**, not `scripts/views.py`. It's 4 lines, only the API routes use it, and it's parity-only scaffolding that M8 will delete. Keeping `views.py` free of HTTP-layer concerns means cleaner deletion later.
|
||||
2. **No data-loading helper extraction.** Each shadow route duplicates ~8 lines of cache loads from its sibling HTML route. A helper would have to thread `attendance_url` / `payments_url` / `bank_account` and the adults-vs-juniors-vs-payments branching back out — net negative for code that M8 will erase wholesale.
|
||||
3. **Drop `record_step` calls in API routes.** `record_step` only feeds `inject_render_time` (a Jinja `context_processor`); JSON responses don't go through templates, so timing breakdown has no consumer.
|
||||
4. **`/api/version` is a one-liner.** `BUILD_META` already has the keys (`tag`, `commit`, `build_date`) Go emits. Just `jsonify(BUILD_META)`. Existing `/version` route stays as-is — the new endpoint sits alongside.
|
||||
5. **Tests assert key sets and unwrap, not values.** Hard-code `EXPECTED_ADULTS_KEYS` etc. as module constants in [tests/test_app.py](tests/test_app.py). Catches drift in unit tests rather than waiting for M5.4 parity diffs. Type-check the unwrapped fields (`isinstance(member_data, dict)` etc.) to prove the shim ran.
|
||||
|
||||
### The shim
|
||||
|
||||
```python
|
||||
def _unwrap_view_model_for_api(vm: dict) -> dict:
|
||||
out = dict(vm)
|
||||
out["member_data"] = _json.loads(out.pop("member_data"))
|
||||
out["month_labels"] = _json.loads(out.pop("month_labels_json"))
|
||||
out["raw_payments"] = _json.loads(out.pop("raw_payments_json"))
|
||||
return out
|
||||
```
|
||||
|
||||
Note the **rename**: `month_labels_json` → `month_labels`, `raw_payments_json` → `raw_payments` (matches Go contract per [adults.go](go/internal/web/api/adults.go) JSON tags).
|
||||
|
||||
### Route skeleton (`/api/adults`)
|
||||
|
||||
```python
|
||||
@app.route("/api/adults")
|
||||
def api_adults():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||
if not members_data:
|
||||
return jsonify({"error": "no data"}), 503
|
||||
members, sorted_months = members_data
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
|
||||
exceptions = get_cached_data(
|
||||
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||
PAYMENTS_SHEET_ID, CREDENTIALS_PATH,
|
||||
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||
)
|
||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||
vm = build_adults_view_model(
|
||||
members, sorted_months, result, transactions,
|
||||
datetime.now().strftime("%Y-%m"),
|
||||
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
|
||||
)
|
||||
return jsonify(_unwrap_view_model_for_api(vm))
|
||||
```
|
||||
|
||||
`/api/juniors` mirrors with `adapt_junior_members` + `JUNIOR_SHEET_GID` + `build_juniors_view_model`. `/api/payments` skips the unwrap (its builder has no JSON-string fields): `return jsonify(vm)`. `/api/version`: `return jsonify(BUILD_META)`.
|
||||
|
||||
## Files to modify
|
||||
|
||||
- [app.py](app.py)
|
||||
- L10: add `jsonify` to `from flask import ...`.
|
||||
- Add `_unwrap_view_model_for_api` near [BUILD_META](app.py#L67) (it already imports `json as _json`).
|
||||
- Add four routes: `/api/version`, `/api/adults`, `/api/juniors`, `/api/payments`. Place them after [`/version`](app.py#L143) for grouping.
|
||||
- [tests/test_app.py](tests/test_app.py)
|
||||
- Add `EXPECTED_ADULTS_KEYS`, `EXPECTED_JUNIORS_KEYS`, `EXPECTED_PAYMENTS_KEYS`, `EXPECTED_VERSION_KEYS` module constants (sourced from [adults.go](go/internal/web/api/adults.go) / [juniors.go](go/internal/web/api/juniors.go) / [payments.go](go/internal/web/api/payments.go) / [version.go](go/internal/web/api/version.go) JSON tags).
|
||||
- Four new test functions: `test_api_adults`, `test_api_juniors`, `test_api_payments`, `test_api_version`. Reuse the existing `_bypass_cache` patcher and the `fetch_sheet_data` / `fetch_exceptions` / `get_members_with_fees` / `get_junior_members_with_fees` mocks already in the file.
|
||||
- Each adults/juniors test asserts: `200`, `response.is_json`, `set(json.keys()) == EXPECTED_*_KEYS`, `isinstance(json["member_data"], dict)`, `isinstance(json["month_labels"], dict)`, `isinstance(json["raw_payments"], dict)`.
|
||||
- [CHANGELOG.md](CHANGELOG.md): post-merge entry per CLAUDE.md format.
|
||||
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:102](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L102): tick M5.3 with merge SHA.
|
||||
|
||||
## Reusable existing code
|
||||
|
||||
- [`build_adults_view_model`](scripts/views.py#L64), [`build_juniors_view_model`](scripts/views.py#L240), [`build_payments_view_model`](scripts/views.py#L432) — call as-is, no changes.
|
||||
- [`reconcile`](scripts/match_payments.py) and [`adapt_junior_members`](scripts/views.py#L48) — same.
|
||||
- [`get_cached_data`](app.py#L36), [`fetch_sheet_data`](scripts/match_payments.py), [`fetch_exceptions`](scripts/match_payments.py), [`get_members_with_fees`](scripts/attendance.py), [`get_junior_members_with_fees`](scripts/attendance.py) — same call sites as the HTML routes.
|
||||
- [`BUILD_META`](app.py#L67) — already shaped to match Go's `VersionResponse`.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `make test` — all existing tests still pass; four new `test_api_*` tests pass.
|
||||
2. `python -c "import app"` — catches the missing `jsonify` import.
|
||||
3. `make web-py`, then:
|
||||
- `curl -s localhost:5001/api/version | jq .` → `{tag, commit, build_date}`.
|
||||
- `curl -s localhost:5001/api/adults | jq 'keys'` → 14 keys, no `_json` suffix anywhere.
|
||||
- `curl -s localhost:5001/api/adults | jq '.member_data | type, .month_labels | type, .raw_payments | type'` → all `"object"` (proves unwrap).
|
||||
- Same checks on `/api/juniors` and `/api/payments`.
|
||||
4. Visit `/adults`, `/juniors`, `/payments` in browser — HTML still renders identically (regression check on builders).
|
||||
5. **Optional pre-M5.4 peek:** `make web-go` on :8080 + `make web-py` on :5001, then `diff <(curl -s :5001/api/adults | jq -S .) <(curl -s :8080/api/adults | jq -S .)`. Expect non-zero diff (raw transaction key shape, see below) — that is fine; M5.4 surfaces and resolves these.
|
||||
|
||||
## Out of scope (M5.4 will surface and resolve)
|
||||
|
||||
These are known parity friction points; **don't fix in M5.3** — the whole point of M5.4 is to enumerate them:
|
||||
|
||||
- **`raw_payments[name][i]` row shape**: Python emits raw Google Sheets row dicts with column-header keys (e.g. `"VS"`, `"Sync ID"`); Go's `RawTransaction` uses snake_case (`vs`, `sync_id`). Keys and types will diverge.
|
||||
- **`unmatched[]`**: same divergence (same raw row dicts).
|
||||
- **`null` vs missing keys**: Python omits keys never set; Go zero-value structs may emit `null`/`""` depending on `omitempty`.
|
||||
- **`Decimal` / float precision** in `grouped_payments` amounts.
|
||||
- **Field insertion order**: `jsonify` preserves insertion order (Python 3.7+); Go marshals struct fields in declaration order. Likely fine, parity tool will tell.
|
||||
|
||||
## Branch + MR
|
||||
|
||||
Per CLAUDE.md: branch `feat/go-m5-3-python-api-shadow`, push with `-u`, open MR via `tea pr create --title ... --description ... --base main --head feat/go-m5-3-python-api-shadow`. Do not merge from CLI.
|
||||
152
docs/plans/2026-05-07-2254-m5-4-parity-binary.md
Normal file
152
docs/plans/2026-05-07-2254-m5-4-parity-binary.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# M5.4 — Parity diff binary (`cmd/parity`) + `make parity`
|
||||
|
||||
## Context
|
||||
|
||||
Per [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:103](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L103), M5.4 is the next milestone in the Go rewrite. M5.1–M5.3 already landed:
|
||||
|
||||
- M5.1: hand-authored Go response structs at [go/internal/web/api/](go/internal/web/api/) + JSON Schemas in [go/tests/fixtures/api-schema/](go/tests/fixtures/api-schema/).
|
||||
- M5.2: Go `/api/version|adults|juniors|payments` handlers in [go/internal/web/api/handler.go](go/internal/web/api/handler.go).
|
||||
- M5.3: Python shadow endpoints in [app.py:157-224](app.py#L157-L224) using `_unwrap_view_model_for_api` for the same JSON shape.
|
||||
|
||||
What's missing: a tool that proves the two backends actually agree on the wire. The M5 gate says "byte-equal JSON between Python and Go for every route." Without a diffing tool, drift between the two implementations slips in silently and we can't gate further milestones (M6 frontend, M7 watch period) on parity.
|
||||
|
||||
This task delivers the **parity contract enforcer**: a Go binary that fetches `/api/*` from both backends, scrubs an allowlist of expected diffs, and prints `cmp.Diff` for everything else. Both backends read the same live Google Sheets — that shared state is what makes parity meaningful, no scenario fixtures needed.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
- New binary `go/cmd/parity/main.go` plus a small support package for the allowlist scrubber.
|
||||
- A unit test for the scrubber.
|
||||
- New `make parity` target.
|
||||
- `go-cmp` promoted to a direct dependency.
|
||||
|
||||
Out of scope (explicitly):
|
||||
- CI integration / nightly job — that's M7.2.
|
||||
- Fixture-driven offline parity — pure-fn parity already runs via `make go-parity` (M3).
|
||||
- Hooking `make parity` into `make go-test-all` — leave it manual since it requires two live servers.
|
||||
|
||||
## Approach
|
||||
|
||||
### 1. New binary: `go/cmd/parity/main.go`
|
||||
|
||||
Standalone binary (mirrors task spec — not a subcommand of `fuj`). Stdlib `flag`, no third-party CLI lib.
|
||||
|
||||
**Flags:**
|
||||
- `-py` — Python base URL, default `http://localhost:5001`
|
||||
- `-go` — Go base URL, default `http://localhost:8080`
|
||||
- `-route` — optional single route to diff (e.g. `/api/adults`); empty means iterate all four
|
||||
- `-timeout` — per-request timeout, default `30s` (sheet fetches are slow on cold cache)
|
||||
|
||||
**Routes**, hard-coded:
|
||||
```go
|
||||
var routes = []string{"/api/version", "/api/adults", "/api/juniors", "/api/payments"}
|
||||
```
|
||||
All `GET`, no query params (verified in [app.py:157-224](app.py#L157-L224) and [go/internal/web/api/handler.go:27-68](go/internal/web/api/handler.go#L27-L68)).
|
||||
|
||||
**Per route, the binary:**
|
||||
1. `GET py+route` and `GET go+route` (sequential — keep it simple; total wall time ~1–2s on warm cache).
|
||||
2. Verify both return HTTP 200; on non-200, print body and mark route as ERROR.
|
||||
3. Decode each body into `map[string]any` (NOT into `api.AdultsResponse` — using the typed struct would silently drop unknown Python-side keys, defeating the diff. Map gives `cmp.Diff` clean dotted-path field names for free.).
|
||||
4. Run `scrub(m, allowlist)` on both decoded maps.
|
||||
5. `diff := cmp.Diff(pyMap, goMap, cmpopts.EquateEmpty())`.
|
||||
6. If `diff != ""`, print `=== /api/X ===` header followed by the diff; track for exit code.
|
||||
|
||||
**Exit codes:**
|
||||
- `0` — all routes match (or the only diffs were under the allowlist).
|
||||
- `1` — at least one route had a non-allowlisted diff.
|
||||
- `2` — at least one route failed to fetch / parse (HTTP error, timeout, non-JSON body).
|
||||
|
||||
This makes the binary CI-friendly when M7.2 lands.
|
||||
|
||||
**Output**: human-readable to stdout — header per route, then "OK" or the diff. Final summary line: `parity: 4/4 routes match` or `parity: 2/4 match, 1 diff, 1 error`.
|
||||
|
||||
### 2. Allowlist scrubber
|
||||
|
||||
Live in same package (`main`). Keep it tiny — no need for a separate sub-package.
|
||||
|
||||
```go
|
||||
var defaultAllowlist = []string{"render_time.total", "build_meta"}
|
||||
|
||||
// scrub walks m and deletes any key whose dotted path matches an allowlist entry.
|
||||
// "render_time.total" deletes m["render_time"]["total"] only (preserves render_time.breakdown).
|
||||
// "build_meta" (no dot) deletes m["build_meta"] entirely.
|
||||
func scrub(m map[string]any, paths []string) { ... }
|
||||
```
|
||||
|
||||
Today these fields **don't appear in the JSON** — they're Jinja template-only ([app.py:91-110](app.py#L91-L110)) and the Go side only logs render time via [go/internal/web/middleware/timer.go](go/internal/web/middleware/timer.go). So today the scrub is a no-op. The implementation is forward-compatible insurance: if someone later adds either field to a JSON response on one side only, the parity binary already tolerates it.
|
||||
|
||||
The implementation note in [go/internal/web/middleware/timer.go](go/internal/web/middleware/timer.go) ("the elapsed value maps to render_time.total in the M5 JSON allowlist") confirms this is the intended design.
|
||||
|
||||
### 3. Unit test
|
||||
|
||||
Add `go/cmd/parity/scrub_test.go` with a couple of cases:
|
||||
- Scrubbing top-level key (`build_meta`) — key removed; siblings untouched.
|
||||
- Scrubbing nested key (`render_time.total`) — only `total` removed; `breakdown` preserved.
|
||||
- Path that doesn't exist — no-op, no error.
|
||||
- Map without the parent key — no-op (don't panic).
|
||||
|
||||
Runs as part of normal `make go-test` (no `-tags` needed).
|
||||
|
||||
### 4. Makefile target
|
||||
|
||||
Add to [Makefile](Makefile):
|
||||
|
||||
```make
|
||||
parity:
|
||||
cd $(GO_SRC) && go run ./cmd/parity $(ARGS)
|
||||
```
|
||||
|
||||
Add `parity` to the `.PHONY` line at [Makefile:1](Makefile#L1) and a `help` entry like:
|
||||
|
||||
```
|
||||
@echo " make parity - Diff /api/* between web-py (:5001) and web-go (:8080); both must be running"
|
||||
```
|
||||
|
||||
Don't depend on `go-build` — `go run` compiles ad-hoc, and parity is interactive enough that the slight rebuild cost doesn't matter.
|
||||
|
||||
### 5. Dependency
|
||||
|
||||
`github.com/google/go-cmp v0.7.0` is already a transitive dep ([go/go.sum:24-25](go/go.sum#L24-L25)) but not in `go.mod`'s `require` block. After adding the import, run `go mod tidy` inside `go/` to promote it to direct. No new external deps.
|
||||
|
||||
## Files to add / modify
|
||||
|
||||
**Add:**
|
||||
- [go/cmd/parity/main.go](go/cmd/parity/main.go) — flags, route loop, fetch+scrub+diff, exit codes
|
||||
- [go/cmd/parity/scrub_test.go](go/cmd/parity/scrub_test.go) — unit tests for the scrubber
|
||||
|
||||
**Modify:**
|
||||
- [Makefile](Makefile) — `.PHONY`, help block, `parity` target
|
||||
- [go/go.mod](go/go.mod) — promote `go-cmp` to direct (via `go mod tidy`)
|
||||
- [go/go.sum](go/go.sum) — likely unchanged (already pinned)
|
||||
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) — tick M5.4, add commit SHA
|
||||
- [CHANGELOG.md](CHANGELOG.md) — entry per project convention
|
||||
|
||||
**Per [CLAUDE.md](CLAUDE.md) plans convention:** copy this plan to `docs/plans/2026-05-07-HHMM-m5-4-parity-binary.md` (timestamp via `date "+%Y-%m-%d-%H%M"`) before opening the MR, since plan files are committed for posterity.
|
||||
|
||||
## Existing utilities to reuse
|
||||
|
||||
- `cmp.Diff` + `cmpopts.EquateEmpty()` from `github.com/google/go-cmp/cmp` (already in go.sum).
|
||||
- Stdlib `net/http`, `encoding/json`, `flag` — sufficient; no need to pull a CLI framework.
|
||||
- No existing scrubber to reuse — the one in [scripts/scrub_fixtures.py](scripts/scrub_fixtures.py) operates on the *capture* path and renames PII; the parity scrubber is a different concern (path-based deletion of expected diffs).
|
||||
|
||||
## Verification
|
||||
|
||||
Manual smoke test (the binary is meant to be run against live servers):
|
||||
|
||||
1. **Sanity / build:** `cd go && go build ./cmd/parity && go test ./cmd/parity/...` — compiles + scrubber unit test passes.
|
||||
2. **Both backends up:**
|
||||
- Terminal A: `make web-py`
|
||||
- Terminal B: `make web-go`
|
||||
- Terminal C: `make parity`
|
||||
Expected: `parity: 4/4 routes match`, exit 0.
|
||||
3. **Negative test:** add a literal extra field to one Go handler temporarily (e.g. `"diagnostic": "test"` in `VersionResponse`), rebuild, re-run `make parity`. Expected: non-zero exit, diff shown for `/api/version`. Revert.
|
||||
4. **Allowlist test:** in a unit test (or by manually constructing a payload with `render_time.total` injected), confirm the scrubber removes it before the diff stage.
|
||||
5. **CHANGELOG** entry added; progress tracker ticked with the merge commit SHA.
|
||||
|
||||
Per [CLAUDE.md](CLAUDE.md) branching policy: this is a feature, so work happens on `feat/go-m5-4-parity-binary`, push with `-u`, open MR with `tea pr create --base main --head feat/go-m5-4-parity-binary`. User merges in Gitea.
|
||||
|
||||
## Notes & risks
|
||||
|
||||
- **Cache-warmth dependency:** Both backends cache aggressively. A cold-cache fetch on Python can take 30s+ — hence the configurable timeout. If parity is run immediately after `flush-cache`, expect the first run to be slow.
|
||||
- **Order-sensitive lists:** `cmp.Diff` on `map[string]any` treats slices as ordered. If either backend ever returns members/transactions in a different order, the diff will flag it — that's a real parity bug, not a false positive, so this is correct behavior. If we hit ordering instability later, fix the source, don't add `cmpopts.SortSlices`.
|
||||
- **Float formatting:** Both sides go through `encoding/json` (Go) and `jsonify` (Python `json.dumps`). Floats in totals/balances may format differently (`123` vs `123.0`). If this surfaces, the Go side already uses typed structs with `int` where appropriate — investigate at the source rather than allowlisting.
|
||||
133
go/cmd/parity/main.go
Normal file
133
go/cmd/parity/main.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
)
|
||||
|
||||
// /api/version is intentionally excluded — it returns each binary's own build
|
||||
// identity (tag/commit/build_date), which differs by design between independently
|
||||
// built backends. Pass -route /api/version to inspect it manually.
|
||||
var allRoutes = []string{"/api/adults", "/api/juniors", "/api/payments"}
|
||||
|
||||
// defaultAllowlist holds dotted key paths to strip before diffing.
|
||||
// render_time.total is forward-compatible insurance: today it lives in the Jinja
|
||||
// template context only (app.py inject_render_time) and is logged via
|
||||
// middleware/timer.go on the Go side, so it isn't in any JSON response. If either
|
||||
// side ever surfaces it under a render_time envelope, the scrubber handles it.
|
||||
var defaultAllowlist = []string{"render_time.total"}
|
||||
|
||||
func main() {
|
||||
pyURL := flag.String("py", "http://localhost:5001", "Python backend base URL")
|
||||
goURL := flag.String("go", "http://localhost:8080", "Go backend base URL")
|
||||
route := flag.String("route", "", "single route to diff, e.g. /api/adults (default: all)")
|
||||
timeout := flag.Duration("timeout", 30*time.Second, "per-request HTTP timeout")
|
||||
flag.Parse()
|
||||
|
||||
client := &http.Client{Timeout: *timeout}
|
||||
|
||||
targets := allRoutes
|
||||
if *route != "" {
|
||||
targets = []string{*route}
|
||||
}
|
||||
|
||||
matched, diffs, errs := 0, 0, 0
|
||||
|
||||
for _, r := range targets {
|
||||
pyMap, err1 := fetch(client, *pyURL+r)
|
||||
goMap, err2 := fetch(client, *goURL+r)
|
||||
|
||||
if err1 != nil || err2 != nil {
|
||||
fmt.Printf("=== %s ===\n", r)
|
||||
if err1 != nil {
|
||||
fmt.Printf("ERROR (py): %v\n", err1)
|
||||
}
|
||||
if err2 != nil {
|
||||
fmt.Printf("ERROR (go): %v\n", err2)
|
||||
}
|
||||
fmt.Println()
|
||||
errs++
|
||||
continue
|
||||
}
|
||||
|
||||
scrub(pyMap, defaultAllowlist)
|
||||
scrub(goMap, defaultAllowlist)
|
||||
|
||||
diff := cmp.Diff(pyMap, goMap, cmpopts.EquateEmpty())
|
||||
if diff == "" {
|
||||
fmt.Printf("=== %s ===\nOK\n\n", r)
|
||||
matched++
|
||||
} else {
|
||||
fmt.Printf("=== %s ===\n%s\n", r, diff)
|
||||
diffs++
|
||||
}
|
||||
}
|
||||
|
||||
total := len(targets)
|
||||
fmt.Printf("parity: %d/%d routes match", matched, total)
|
||||
if diffs > 0 {
|
||||
fmt.Printf(", %d diff", diffs)
|
||||
if diffs > 1 {
|
||||
fmt.Print("s")
|
||||
}
|
||||
}
|
||||
if errs > 0 {
|
||||
fmt.Printf(", %d error", errs)
|
||||
if errs > 1 {
|
||||
fmt.Print("s")
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if errs > 0 {
|
||||
os.Exit(2)
|
||||
}
|
||||
if diffs > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func fetch(client *http.Client, url string) (map[string]any, error) {
|
||||
resp, err := client.Get(url) //nolint:noctx
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
return nil, fmt.Errorf("decode JSON: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// scrub removes keys from m whose dotted paths appear in paths.
|
||||
// A bare segment (no dot) deletes a top-level key.
|
||||
// A two-segment path "parent.child" deletes child from m["parent"] if it is a map.
|
||||
func scrub(m map[string]any, paths []string) {
|
||||
for _, path := range paths {
|
||||
parts := strings.SplitN(path, ".", 2)
|
||||
if len(parts) == 1 {
|
||||
delete(m, parts[0])
|
||||
} else {
|
||||
if child, ok := m[parts[0]].(map[string]any); ok {
|
||||
delete(child, parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
57
go/cmd/parity/scrub_test.go
Normal file
57
go/cmd/parity/scrub_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestScrubTopLevel(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"build_meta": map[string]any{"tag": "v1"},
|
||||
"other": "keep",
|
||||
}
|
||||
scrub(m, []string{"build_meta"})
|
||||
if _, ok := m["build_meta"]; ok {
|
||||
t.Error("expected build_meta to be removed")
|
||||
}
|
||||
if m["other"] != "keep" {
|
||||
t.Error("expected other to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubNested(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"render_time": map[string]any{
|
||||
"total": "0.123",
|
||||
"breakdown": "fetch:0.1s",
|
||||
},
|
||||
"other": "keep",
|
||||
}
|
||||
scrub(m, []string{"render_time.total"})
|
||||
rt, ok := m["render_time"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("render_time should still be present")
|
||||
}
|
||||
if _, ok := rt["total"]; ok {
|
||||
t.Error("expected render_time.total to be removed")
|
||||
}
|
||||
if rt["breakdown"] != "fetch:0.1s" {
|
||||
t.Error("expected render_time.breakdown to be preserved")
|
||||
}
|
||||
if m["other"] != "keep" {
|
||||
t.Error("expected other to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubMissingPath(t *testing.T) {
|
||||
m := map[string]any{"foo": "bar"}
|
||||
scrub(m, []string{"nonexistent", "render_time.total"})
|
||||
if m["foo"] != "bar" {
|
||||
t.Error("expected foo to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubNestedParentNotMap(t *testing.T) {
|
||||
m := map[string]any{"render_time": "not-a-map"}
|
||||
scrub(m, []string{"render_time.total"})
|
||||
if m["render_time"] != "not-a-map" {
|
||||
t.Error("expected render_time to be unchanged when it is not a map")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ module fuj-management/go
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/invopop/jsonschema v0.14.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/text v0.36.0
|
||||
|
||||
@@ -50,7 +50,7 @@ func Load() Config {
|
||||
return Config{
|
||||
CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"),
|
||||
BankAccount: env("BANK_ACCOUNT", "CZ8520100000002800359168"),
|
||||
CacheDir: env("CACHE_DIR", "tmp"),
|
||||
CacheDir: env("CACHE_DIR", "tmp/go"),
|
||||
CacheTTL: envDuration("CACHE_TTL_SECONDS", 300),
|
||||
CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300),
|
||||
DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10),
|
||||
|
||||
@@ -142,7 +142,13 @@ func parseDates(header []string) []struct {
|
||||
}
|
||||
var dt time.Time
|
||||
var err error
|
||||
for _, fmt_ := range []string{"02.01.2006", "01/02/2006"} {
|
||||
// Use the unpadded reference forms ("2.1" and "1/2"): Go's time.Parse
|
||||
// accepts both single-digit and zero-padded inputs against them, so
|
||||
// "1.6.2026", "01.06.2026", "23.3.2026" all parse. Czech sheet authors
|
||||
// drop the leading zero on dates ≤ 9 — Python's strptime is lenient
|
||||
// the same way; the previous "02.01.2006" form silently dropped those
|
||||
// columns and undercounted attendance.
|
||||
for _, fmt_ := range []string{"2.1.2006", "1/2/2006"} {
|
||||
dt, err = time.Parse(fmt_, raw)
|
||||
if err == nil {
|
||||
break
|
||||
@@ -394,9 +400,19 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
||||
return fmt.Sprint(row[i])
|
||||
}
|
||||
|
||||
// getRaw returns row[i] without stringifying — needed for FormatDate to
|
||||
// dispatch on the underlying numeric type (Sheets returns serial-day
|
||||
// numbers as float64). Stringifying first defeats that dispatch.
|
||||
getRaw := func(row []any, i int) any {
|
||||
if i < 0 || i >= len(row) {
|
||||
return nil
|
||||
}
|
||||
return row[i]
|
||||
}
|
||||
|
||||
var txns []reconcile.Transaction
|
||||
for _, row := range rows[1:] {
|
||||
dateStr := matching.FormatDate(getVal(row, idxDate))
|
||||
dateStr := matching.FormatDate(getRaw(row, idxDate))
|
||||
amountRaw := row[idxAmount]
|
||||
if idxAmount < 0 || idxAmount >= len(row) {
|
||||
amountRaw = ""
|
||||
|
||||
@@ -114,12 +114,15 @@ func TestLoadJuniors(t *testing.T) {
|
||||
|
||||
func TestLoadTransactions(t *testing.T) {
|
||||
// Sheets fake keyed by "<spreadsheetID>/<range>" — use the real constant.
|
||||
// Row 1 uses a pre-formatted date string; row 2 uses the numeric Sheets
|
||||
// serial-day form (float64) — the API returns either depending on cell
|
||||
// formatting, and FormatDate must handle both.
|
||||
paymentsKey := config.PaymentsSheetID + "/A1:Z"
|
||||
sh := &sheets.Fake{Values: map[string][][]any{
|
||||
paymentsKey: {
|
||||
{"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"},
|
||||
{"2026-04-01", 700.0, "", "Alice", "2026-04", "", "Alice Bank", "", "fee", "", "abc"},
|
||||
{"2026-05-01", 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"},
|
||||
{46147.0, 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"}, // 46147 serial-day = 2026-05-05
|
||||
},
|
||||
}}
|
||||
s := buildSources(t, &attendance.Fake{}, sh)
|
||||
@@ -137,6 +140,12 @@ func TestLoadTransactions(t *testing.T) {
|
||||
if txns[0].Amount != 700 {
|
||||
t.Errorf("txn[0].Amount: want 700, got %v", txns[0].Amount)
|
||||
}
|
||||
if txns[0].Date != "2026-04-01" {
|
||||
t.Errorf("txn[0].Date: want 2026-04-01, got %q", txns[0].Date)
|
||||
}
|
||||
if txns[1].Date != "2026-05-05" {
|
||||
t.Errorf("txn[1].Date (numeric serial-day): want 2026-05-05, got %q", txns[1].Date)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadExceptions(t *testing.T) {
|
||||
@@ -165,6 +174,28 @@ func TestLoadExceptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDates_SingleDigitDayMonth covers the regression where Go's strict
|
||||
// "02.01.2006" format dropped header cells written without leading zeros
|
||||
// (e.g. "1.6.2026", "23.3.2026"), causing attendance undercounts and missing
|
||||
// months on the /api/juniors response. Czech sheet authors drop the zero
|
||||
// pad freely; Python's strptime tolerates it, so the parsers must match.
|
||||
func TestParseDates_SingleDigitDayMonth(t *testing.T) {
|
||||
// Czech form ("DD.MM.YYYY", with leading zeros optional) is the primary
|
||||
// path. The "M/D/YYYY" fallback mirrors Python's %m/%d/%Y secondary
|
||||
// strptime branch — month-first, day-second.
|
||||
header := []string{"Jméno", "Tier", "", "01.06.2026", "1.6.2026", "23.3.2026", "6.4.2026", "01/02/2026", "1/2/2026"}
|
||||
got := parseDates(header)
|
||||
want := []string{"2026-06", "2026-06", "2026-03", "2026-04", "2026-01", "2026-01"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("parseDates: got %d entries, want %d (%v)", len(got), len(want), got)
|
||||
}
|
||||
for i, e := range got {
|
||||
if e.month != want[i] {
|
||||
t.Errorf("parseDates[%d].month = %q, want %q (raw=%q)", i, e.month, want[i], header[e.col])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TTL smoke test: second call within TTL must not call fetch again.
|
||||
func TestLoadAdults_CacheHit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -236,6 +236,8 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
|
||||
idx_sender = get_col_index("Sender")
|
||||
idx_message = get_col_index("Message")
|
||||
idx_bank_id = get_col_index("Bank ID")
|
||||
idx_vs = get_col_index("VS")
|
||||
idx_sync_id = get_col_index("Sync ID")
|
||||
|
||||
required = {"Date": idx_date, "Amount": idx_amount, "Person": idx_person, "Purpose": idx_purpose}
|
||||
missing = [name for name, idx in required.items() if idx == -1]
|
||||
@@ -247,16 +249,33 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
|
||||
def get_val(idx):
|
||||
return row[idx] if idx != -1 and idx < len(row) else ""
|
||||
|
||||
def get_str(idx):
|
||||
v = get_val(idx)
|
||||
if isinstance(v, float) and v.is_integer():
|
||||
return str(int(v))
|
||||
return str(v)
|
||||
|
||||
def get_float(idx):
|
||||
v = get_val(idx)
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
try:
|
||||
return float(str(v).strip())
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
tx = {
|
||||
"date": format_date(get_val(idx_date)),
|
||||
"amount": get_val(idx_amount),
|
||||
"amount": get_float(idx_amount),
|
||||
"manual_fix": get_val(idx_manual),
|
||||
"person": get_val(idx_person),
|
||||
"purpose": get_val(idx_purpose),
|
||||
"inferred_amount": get_val(idx_inferred_amount),
|
||||
"sender": get_val(idx_sender),
|
||||
"message": get_val(idx_message),
|
||||
"vs": get_str(idx_vs),
|
||||
"message": get_str(idx_message),
|
||||
"bank_id": get_val(idx_bank_id),
|
||||
"sync_id": get_val(idx_sync_id),
|
||||
}
|
||||
transactions.append(tx)
|
||||
|
||||
|
||||
@@ -310,8 +310,9 @@ def build_juniors_view_model(
|
||||
cell_text = "-"
|
||||
amount_to_pay = 0
|
||||
|
||||
if expected == "?" or (isinstance(expected, int) and expected > 0):
|
||||
if expected == "?":
|
||||
is_unknown = original_expected == "?"
|
||||
if is_unknown or (isinstance(expected, int) and expected > 0):
|
||||
if is_unknown:
|
||||
status = "empty"
|
||||
cell_text = f"?{count_str}"
|
||||
elif paid >= expected:
|
||||
@@ -339,7 +340,7 @@ def build_juniors_view_model(
|
||||
status = "surplus"
|
||||
cell_text = f"PAID {paid}"
|
||||
|
||||
if (isinstance(expected, int) and expected > 0) or paid > 0:
|
||||
if (not is_unknown and isinstance(expected, int) and expected > 0) or paid > 0:
|
||||
tooltip = f"Received: {paid}, Expected: {expected}"
|
||||
else:
|
||||
tooltip = ""
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import unittest
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
from app import app
|
||||
|
||||
EXPECTED_ADULTS_KEYS = {
|
||||
"months", "raw_months", "results", "totals", "member_data", "month_labels",
|
||||
"raw_payments", "credits", "debts", "unmatched", "attendance_url",
|
||||
"payments_url", "bank_account", "current_month",
|
||||
}
|
||||
EXPECTED_JUNIORS_KEYS = EXPECTED_ADULTS_KEYS
|
||||
EXPECTED_PAYMENTS_KEYS = {"grouped_payments", "sorted_people", "attendance_url", "payments_url"}
|
||||
EXPECTED_VERSION_KEYS = {"tag", "commit", "build_date"}
|
||||
|
||||
|
||||
def _bypass_cache(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
|
||||
"""Test helper: call fetch_func directly, bypassing the cache layer."""
|
||||
@@ -97,5 +107,83 @@ class TestWebApp(unittest.TestCase):
|
||||
self.assertIn(b'500/500 CZK', response.data)
|
||||
self.assertIn(b'?', response.data)
|
||||
|
||||
def test_api_version(self):
|
||||
"""Test /api/version returns BUILD_META keys as JSON."""
|
||||
response = self.client.get('/api/version')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.is_json)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(set(data.keys()), EXPECTED_VERSION_KEYS)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
@patch('app.fetch_exceptions', return_value={})
|
||||
@patch('app.get_members_with_fees')
|
||||
def test_api_adults(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||
"""Test /api/adults returns JSON with correct top-level keys and unwrapped fields."""
|
||||
mock_get_members.return_value = (
|
||||
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||
['2026-01']
|
||||
)
|
||||
mock_fetch_sheet.return_value = [{
|
||||
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
|
||||
'purpose': '2026-01', 'message': 'test payment',
|
||||
'sender': 'External Bank User', 'inferred_amount': 750,
|
||||
'vs': '', 'sync_id': 'abc123',
|
||||
}]
|
||||
response = self.client.get('/api/adults')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.is_json)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(set(data.keys()), EXPECTED_ADULTS_KEYS)
|
||||
self.assertIsInstance(data['member_data'], dict)
|
||||
self.assertIsInstance(data['month_labels'], dict)
|
||||
self.assertIsInstance(data['raw_payments'], dict)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
@patch('app.fetch_exceptions', return_value={})
|
||||
@patch('app.get_junior_members_with_fees')
|
||||
def test_api_juniors(self, mock_get_junior_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||
"""Test /api/juniors returns JSON with correct top-level keys and unwrapped fields."""
|
||||
mock_get_junior_members.return_value = (
|
||||
[
|
||||
('Junior One', 'J', {'2026-01': (500, 3, 0, 3)}),
|
||||
('Junior Two', 'X', {'2026-01': ('?', 1, 0, 1)}),
|
||||
],
|
||||
['2026-01']
|
||||
)
|
||||
mock_fetch_sheet.return_value = [{
|
||||
'date': '2026-01-15', 'amount': 500, 'person': 'Junior One',
|
||||
'purpose': '2026-01', 'message': '', 'sender': 'Parent', 'inferred_amount': 500,
|
||||
'vs': '', 'sync_id': 'def456',
|
||||
}]
|
||||
response = self.client.get('/api/juniors')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.is_json)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(set(data.keys()), EXPECTED_JUNIORS_KEYS)
|
||||
self.assertIsInstance(data['member_data'], dict)
|
||||
self.assertIsInstance(data['month_labels'], dict)
|
||||
self.assertIsInstance(data['raw_payments'], dict)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
def test_api_payments(self, mock_fetch_sheet, mock_cache):
|
||||
"""Test /api/payments returns JSON with correct top-level keys."""
|
||||
mock_fetch_sheet.return_value = [{
|
||||
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
|
||||
'purpose': '2026-01', 'message': 'test', 'sender': 'Someone',
|
||||
'vs': '', 'sync_id': 'ghi789',
|
||||
}]
|
||||
response = self.client.get('/api/payments')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.is_json)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(set(data.keys()), EXPECTED_PAYMENTS_KEYS)
|
||||
self.assertIsInstance(data['grouped_payments'], dict)
|
||||
self.assertIsInstance(data['sorted_people'], list)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user