All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
extractSecondTableRows tracked a boolean inTarget flag and exited on
the first </table> token while inside the target. Any nested <table>
(e.g. pagination markup in the real Fio page) would cause an early
return before reading any data rows, explaining the 0-transaction report.
Fixed by tracking targetDepth instead: depth increments on every <table>
inside the target and we only return when it reaches 0 again.
parseCzechDate also only tried zero-padded layouts ("02.01.2006").
The real Fio transparent page emits non-padded dates ("7.5.2026");
added "2.1.2006" and "2/1/2006" as the preferred layouts.
Also adds a dry-run diagnostic line ("fetched N transaction(s) from Fio")
so the fetch vs dedup split is visible without reading logs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
176 lines
4.6 KiB
Go
176 lines
4.6 KiB
Go
package fio
|
||
|
||
import (
|
||
"context"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func TestAPIClient_ParseResponse(t *testing.T) {
|
||
body, err := os.ReadFile("testdata/api_response.json")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
txns, err := parseAPIResponse(body)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(txns) != 1 {
|
||
t.Fatalf("want 1 txn (outgoing filtered), got %d", len(txns))
|
||
}
|
||
tx := txns[0]
|
||
if tx.Date != "2026-04-10" {
|
||
t.Errorf("date: want '2026-04-10', got %q", tx.Date)
|
||
}
|
||
if tx.Amount != 750 {
|
||
t.Errorf("amount: want 750, got %v", tx.Amount)
|
||
}
|
||
if tx.Sender != "Jana Novakova" {
|
||
t.Errorf("sender: want 'Jana Novakova', got %q", tx.Sender)
|
||
}
|
||
if tx.Message != "duben 2026" {
|
||
t.Errorf("message: want 'duben 2026', got %q", tx.Message)
|
||
}
|
||
if tx.VS != "123" {
|
||
t.Errorf("vs: want '123', got %q", tx.VS)
|
||
}
|
||
if tx.BankID != "12345678901" {
|
||
t.Errorf("bank_id: want '12345678901', got %q", tx.BankID)
|
||
}
|
||
}
|
||
|
||
func TestAPIClient_HTTPRoundTrip(t *testing.T) {
|
||
body, _ := os.ReadFile("testdata/api_response.json")
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
_, _ = w.Write(body)
|
||
}))
|
||
defer srv.Close()
|
||
|
||
c := &apiClient{token: "TESTTOKEN", hc: &overrideClient{base: srv.Client(), baseURL: srv.URL}}
|
||
txns, err := c.FetchTransactions(context.Background(), time.Now().AddDate(0, -1, 0), time.Now())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(txns) != 1 {
|
||
t.Fatalf("want 1 txn, got %d", len(txns))
|
||
}
|
||
}
|
||
|
||
func TestTransparentClient_ParseHTML(t *testing.T) {
|
||
body, err := os.ReadFile("testdata/transparent.html")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
txns, err := parseTransparentHTML(body)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// Only the incoming row (750 CZK) should be kept; -200 is outgoing
|
||
if len(txns) != 1 {
|
||
t.Fatalf("want 1 txn (outgoing filtered), got %d", len(txns))
|
||
}
|
||
tx := txns[0]
|
||
if tx.Date != "2026-04-10" {
|
||
t.Errorf("date: want '2026-04-10', got %q", tx.Date)
|
||
}
|
||
if tx.Amount != 750 {
|
||
t.Errorf("amount: want 750, got %v", tx.Amount)
|
||
}
|
||
if tx.Sender != "Jana Novakova" {
|
||
t.Errorf("sender: want 'Jana Novakova', got %q", tx.Sender)
|
||
}
|
||
if tx.VS != "123" {
|
||
t.Errorf("vs: want '123', got %q", tx.VS)
|
||
}
|
||
if tx.BankID != "" {
|
||
t.Errorf("bank_id: want empty on HTML path, got %q", tx.BankID)
|
||
}
|
||
}
|
||
|
||
func TestParseCzechDate(t *testing.T) {
|
||
cases := []struct{ in, want string }{
|
||
{"10.04.2026", "2026-04-10"},
|
||
{"10/04/2026", "2026-04-10"},
|
||
{"7.5.2026", "2026-05-07"}, // non-padded — real Fio transparent page format
|
||
{"3.12.2025", "2025-12-03"}, // non-padded single-digit day, double-digit month
|
||
{"", ""},
|
||
{"invalid", ""},
|
||
}
|
||
for _, c := range cases {
|
||
if got := parseCzechDate(c.in); got != c.want {
|
||
t.Errorf("parseCzechDate(%q) = %q, want %q", c.in, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestExtractSecondTableRows_NestedTable(t *testing.T) {
|
||
// Regression: a nested <table> inside the target must not cause early exit.
|
||
html := `<table class="table"><tr><td>nav</td></tr></table>
|
||
<table class="table">
|
||
<thead><tr><th>Date</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>7.5.2026</td><td><table><tr><td>nested</td></tr></table></td></tr>
|
||
<tr><td>6.5.2026</td><td></td></tr>
|
||
</tbody>
|
||
</table>`
|
||
rows := extractSecondTableRows([]byte(html))
|
||
if len(rows) != 2 {
|
||
t.Errorf("want 2 data rows, got %d: %v", len(rows), rows)
|
||
}
|
||
}
|
||
|
||
func TestParseCzechAmount(t *testing.T) {
|
||
cases := []struct {
|
||
in string
|
||
want float64
|
||
}{
|
||
{"750,00 CZK", 750},
|
||
{"1.500,00", 1500},
|
||
{"1500.00", 1500},
|
||
{"-200,00 CZK", -200},
|
||
}
|
||
for _, c := range cases {
|
||
if got := parseCzechAmount(c.in); got != c.want {
|
||
t.Errorf("parseCzechAmount(%q) = %v, want %v", c.in, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestFake(t *testing.T) {
|
||
f := &Fake{Transactions: []Transaction{{Date: "2026-04-01", Amount: 500}}}
|
||
txns, err := f.FetchTransactions(context.Background(), time.Now(), time.Now())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(txns) != 1 || txns[0].Date != "2026-04-01" {
|
||
t.Errorf("unexpected: %v", txns)
|
||
}
|
||
}
|
||
|
||
// overrideClient replaces the URL in requests so we can hit a local test server
|
||
// instead of the real Fio URL.
|
||
type overrideClient struct {
|
||
base *http.Client
|
||
baseURL string
|
||
}
|
||
|
||
func (o *overrideClient) Do(req *http.Request) (*http.Response, error) {
|
||
r2, _ := http.NewRequestWithContext(req.Context(), req.Method, o.baseURL+req.URL.Path, nil)
|
||
resp, err := o.base.Do(r2)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// The api client reads the body, so re-serve whatever the test server returned.
|
||
return resp, nil
|
||
}
|
||
|
||
// verify Fake satisfies Client
|
||
var _ Client = (*Fake)(nil)
|
||
|
||
// ensure io.ReadAll isn't called at top level (compile-time reference suppressor)
|
||
var _ = io.ReadAll
|