diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index f335dc9..a53c416 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -14,10 +14,10 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: 1.25 diff --git a/.gitignore b/.gitignore index 6ab1466..037ea91 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ aklrubbish *~ .vscode .idea +.env # binary files aklapi diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a9c0c5d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# AGENTS.md + +## Project Snapshot +- Module: `github.com/rusq/aklapi` +- Language: Go +- Purpose: unofficial Auckland Council API wrapper/service for address lookup and rubbish/recycling collection dates. + +## Repository Map +- `cmd/`: executable entrypoints. +- `addr.go`: address lookup logic. +- `rubbish.go`: rubbish/recycling API logic and response shaping. +- `caches.go`: cache helpers. +- `time.go`: date/time helpers. +- `*_test.go`: unit tests. +- `test_assets/`: fixtures used by tests. + +## Development Commands +- Run tests: `go test ./...` +- Run focused tests: `go test ./... -run ` +- Tidy dependencies: `go mod tidy` +- Build all packages: `go build ./...` + +## Working Conventions +- Prefer small, targeted changes over broad refactors. +- Keep API behavior backward compatible unless explicitly requested. +- Add or update tests for behavioral changes. +- Keep exported identifiers and package-level docs concise. + +## Validation Checklist +Before finishing a code change, run: +1. `go test ./...` +2. `go build ./...` + +If a change only affects docs or comments, note that tests/build were not required. diff --git a/Makefile b/Makefile index 6ed4f2a..36a99d9 100644 --- a/Makefile +++ b/Makefile @@ -2,10 +2,15 @@ SHELL=/bin/sh IMAGE=aklapi -SRC=main.go $(wildcard aklapi/*.go) +SRC=$(wildcard aklapi/*.go) +PKG=./cmd/aklapi server: $(SRC) - go build -o $@ + go build -o $@ $(PKG) + +test: + go test ./... -race +.PHONY: test docker: docker build -t $(IMAGE) . diff --git a/addr.go b/addr.go index a51b69d..f7e55e8 100644 --- a/addr.go +++ b/addr.go @@ -13,6 +13,8 @@ import ( var ( // defined as a variable so it can be overridden in tests. addrURI = `https://www.aucklandcouncil.govt.nz/nextapi/property` + // defined as a variable so tests can replace it. + addrHTTPClient = &http.Client{Timeout: 15 * time.Second} ) // AddrRequest is the address request. @@ -61,8 +63,7 @@ func MatchingPropertyAddresses(ctx context.Context, addrReq *AddrRequest) (*Addr req.URL.RawQuery = q.Encode() start := time.Now() - client := &http.Client{} - resp, err := client.Do(req) + resp, err := addrHTTPClient.Do(req) if err != nil { return nil, err } diff --git a/cmd/aklapi/handlers.go b/cmd/aklapi/handlers.go index d5ebe04..2f2b2e6 100644 --- a/cmd/aklapi/handlers.go +++ b/cmd/aklapi/handlers.go @@ -12,6 +12,11 @@ import ( const dttmLayout = "2006-01-02" +var ( + addressLookup = aklapi.AddressLookup + collectionDayDetail = aklapi.CollectionDayDetail +) + type rrResponse struct { Rubbish string `json:"rubbish,omitempty"` Recycle string `json:"recycle,omitempty"` @@ -24,6 +29,7 @@ func respond(w http.ResponseWriter, data any, code int) { b, err := json.Marshal(data) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) + return } w.Header().Set("Content-Type", "application/json; charset=UTF-8") w.WriteHeader(code) @@ -35,7 +41,7 @@ func rubbish(r *http.Request) (*aklapi.CollectionDayDetailResult, error) { if addr == "" { return nil, errors.New(http.StatusText(http.StatusBadRequest)) } - return aklapi.CollectionDayDetail(r.Context(), addr) + return collectionDayDetail(r.Context(), addr) } func addrHandler(w http.ResponseWriter, r *http.Request) { @@ -44,10 +50,10 @@ func addrHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - resp, err := aklapi.AddressLookup(r.Context(), addr) + resp, err := addressLookup(r.Context(), addr) if err != nil { slog.Error("address lookup failed", "error", err) - http.NotFound(w, r) + http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway) return } respond(w, resp, http.StatusOK) diff --git a/go.mod b/go.mod index f72012d..3c8f7af 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/rusq/aklapi -go 1.24.0 - -toolchain go1.24.2 +go 1.25.0 require ( github.com/PuerkitoBio/goquery v1.11.0 @@ -15,6 +13,6 @@ require ( github.com/andybalholm/cascadia v1.3.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/net v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e2007ec..f693080 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,8 @@ golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/rubbish.go b/rubbish.go index c1371b0..ba0150a 100644 --- a/rubbish.go +++ b/rubbish.go @@ -23,6 +23,8 @@ const ( var ( // defined as a variable so it can be overridden in tests. collectionDayURI = `https://new.aucklandcouncil.govt.nz/en/rubbish-recycling/rubbish-recycling-collections/rubbish-recycling-collection-days/%s.html` + // defined as a variable so tests can replace it. + collectionHTTPClient = &http.Client{Timeout: 15 * time.Second} ) var errSkip = errors.New("skip this date") @@ -118,11 +120,14 @@ func fetchandparse(ctx context.Context, addressID string) (*CollectionDayDetailR if err != nil { return nil, err } - resp, err := http.DefaultClient.Do(req) + resp, err := collectionHTTPClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("collection API returned status code: %d", resp.StatusCode) + } return parse(resp.Body) } diff --git a/rubbish_test.go b/rubbish_test.go index 0ee7865..0518f38 100644 --- a/rubbish_test.go +++ b/rubbish_test.go @@ -163,6 +163,29 @@ func TestCollectionDayDetail(t *testing.T) { } } +func TestFetchAndParse_StatusCodeError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("temporary error")) + })) + defer srv.Close() + + oldURI := collectionDayURI + oldClient := collectionHTTPClient + defer func() { + collectionDayURI = oldURI + collectionHTTPClient = oldClient + }() + + collectionDayURI = srv.URL + "/rubbish/%s" + collectionHTTPClient = srv.Client() + + _, err := fetchandparse(t.Context(), "42") + if err == nil { + t.Fatal("expected error, got nil") + } +} + func TestCollectionDayDetailResult_NextRubbish(t *testing.T) { type fields struct { Collections []RubbishCollection