package web_test import ( "bytes" "embed" "encoding/json" "io" "io/fs" "mime/multipart" "net/http" "net/http/httptest" "os" "strings" "testing" "github.com/nmoniz/any2anexoj/internal/ofigi" "github.com/nmoniz/any2anexoj/internal/web" ) //go:embed testdata var testdataFS embed.FS // stubFigiClient returns a real *ofigi.Client backed by a transport that // replies with a deterministic security type. Tests use it so the upload // pipeline does not hit the public OpenFIGI API. The default stub reports // "ETP" (ETF/ETP) because that matches the majority of rows in // trading212-small.csv; tests that need a different value build their own. func stubFigiClient(t *testing.T, securityType string) *ofigi.Client { t.Helper() return ofigi.NewOpenFIGI(&http.Client{ Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { body := `[{"data":[{"securityType":"` + securityType + `"}]}]` return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header), }, nil }), }, "") } // newTestServer builds a Server backed by the embedded testdata FS and a // stubbed OpenFIGI client so handlers stay offline. func newTestServer(t *testing.T, opts ...web.Option) http.Handler { t.Helper() sub, err := fs.Sub(testdataFS, "testdata") if err != nil { t.Fatalf("failed to sub testdata: %v", err) } state := web.NewState() opts = append([]web.Option{web.WithFigiClient(stubFigiClient(t, "ETP"))}, opts...) srv, err := web.NewServer(state, sub, opts...) if err != nil { t.Fatalf("failed to construct server: %v", err) } return srv.Handler() } // roundTripFunc adapts a plain function to http.RoundTripper. type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } // multipartUpload builds a multipart/form-data request body with a single // "csv" file part from the given file path. func multipartUpload(t *testing.T, filePath string) (*bytes.Buffer, string, func()) { t.Helper() body := &bytes.Buffer{} w := multipart.NewWriter(body) f, err := os.Open(filePath) if err != nil { t.Fatalf("failed to open fixture %q: %v", filePath, err) } defer f.Close() fw, err := w.CreateFormFile("csv", "upload.csv") if err != nil { t.Fatalf("failed to create form file: %v", err) } if _, err := io.Copy(fw, f); err != nil { t.Fatalf("failed to copy file into form: %v", err) } if err := w.Close(); err != nil { t.Fatalf("failed to close multipart writer: %v", err) } return body, w.FormDataContentType(), func() {} } func TestUploadValidCSV(t *testing.T) { handler := newTestServer(t) body, ct, _ := multipartUpload(t, "../../trading212-small.csv") req := httptest.NewRequest(http.MethodPost, "/api/upload", body) req.Header.Set("Content-Type", ct) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d, body=%q", rec.Code, rec.Body.String()) } var resp struct { Table string `json:"table"` Rows int `json:"rows"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v, body=%q", err, rec.Body.String()) } if resp.Table == "" { t.Errorf("expected non-empty table in response") } if resp.Rows <= 0 { t.Errorf("expected rows > 0, got %d", resp.Rows) } // The pretty-printer always emits a box-drawing top border. if !strings.Contains(resp.Table, "┌") { t.Errorf("expected rendered table to contain box-drawing border; got:\n%s", resp.Table) } } func TestUploadMissingFile(t *testing.T) { handler := newTestServer(t) body := &bytes.Buffer{} mw := multipart.NewWriter(body) // Intentionally do not add a "csv" part. if err := mw.Close(); err != nil { t.Fatalf("close multipart: %v", err) } req := httptest.NewRequest(http.MethodPost, "/api/upload", body) req.Header.Set("Content-Type", mw.FormDataContentType()) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected status 400, got %d, body=%q", rec.Code, rec.Body.String()) } var resp struct { Error string `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal error response: %v, body=%q", err, rec.Body.String()) } if resp.Error == "" { t.Errorf("expected non-empty error message, got %q", resp.Error) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { t.Errorf("expected JSON content-type, got %q", ct) } } func TestUploadMalformedRequest(t *testing.T) { handler := newTestServer(t) // A plain string body with no Content-Type is not a valid multipart form. req := httptest.NewRequest(http.MethodPost, "/api/upload", strings.NewReader("not a multipart body")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected status 400, got %d, body=%q", rec.Code, rec.Body.String()) } var resp struct { Error string `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal error response: %v, body=%q", err, rec.Body.String()) } if resp.Error == "" { t.Errorf("expected non-empty error message, got %q", resp.Error) } } // TestUploadMalformedCSV asserts that a syntactically valid multipart upload // whose CSV body is parseable by encoding/csv but rejected by the trading212 // reader (e.g. an unknown action type) returns HTTP 400 (client error), not // 500. This exercises the csvRecordReader -> *ClientError tagging path. func TestUploadMalformedCSV(t *testing.T) { handler := newTestServer(t) // A CSV with the correct header row but a bogus action value triggers // trading212's "parse record type" error, which the handler must map to // 400 via the *ClientError tagging. brokenCSV := "Action,Time,ISIN,Ticker,Name,ID,No. of shares,Price / share,Currency (Price / share),Exchange rate,Result,Currency (Result),Total,Currency (Total),Stamp duty reserve tax,Currency (Stamp duty reserve tax),Currency conversion fee,Currency (Currency conversion fee),French transaction tax,Currency (French transaction tax)\n" + "bogus,2025-06-02 12:33:05,GB00B63H8491,RR,\"Rolls-Royce\",EOF33553786215,0.3847870000,871.0000000000,GBX,84.42052317,,\"EUR\",4.00,\"EUR\",0.02,\"EUR\",0.01,\"EUR\",,\n" body := &bytes.Buffer{} w := multipart.NewWriter(body) fw, err := w.CreateFormFile("csv", "broken.csv") if err != nil { t.Fatalf("create form file: %v", err) } if _, err := fw.Write([]byte(brokenCSV)); err != nil { t.Fatalf("write body: %v", err) } if err := w.Close(); err != nil { t.Fatalf("close multipart: %v", err) } req := httptest.NewRequest(http.MethodPost, "/api/upload", body) req.Header.Set("Content-Type", w.FormDataContentType()) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected status 400 for malformed CSV, got %d, body=%q", rec.Code, rec.Body.String()) } var resp struct { Error string `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal error response: %v, body=%q", err, rec.Body.String()) } if !strings.Contains(resp.Error, "parse record type") { t.Errorf("expected error message to surface the trading212 parse failure, got %q", resp.Error) } } func TestGetTableReturnsLastUpload(t *testing.T) { handler := newTestServer(t) // Initial state: empty table, zero rows. getReq := httptest.NewRequest(http.MethodGet, "/api/table", nil) getRec := httptest.NewRecorder() handler.ServeHTTP(getRec, getReq) if getRec.Code != http.StatusOK { t.Fatalf("initial GET /api/table: expected 200, got %d", getRec.Code) } var initial struct { Table string `json:"table"` Rows int `json:"rows"` } if err := json.Unmarshal(getRec.Body.Bytes(), &initial); err != nil { t.Fatalf("unmarshal initial: %v", err) } if initial.Table != "" || initial.Rows != 0 { t.Fatalf("expected initial state to be empty, got table=%q rows=%d", initial.Table, initial.Rows) } // Upload a CSV. body, ct, _ := multipartUpload(t, "../../trading212-small.csv") upReq := httptest.NewRequest(http.MethodPost, "/api/upload", body) upReq.Header.Set("Content-Type", ct) upRec := httptest.NewRecorder() handler.ServeHTTP(upRec, upReq) if upRec.Code != http.StatusOK { t.Fatalf("upload: expected 200, got %d, body=%q", upRec.Code, upRec.Body.String()) } var uploaded struct { Table string `json:"table"` Rows int `json:"rows"` } if err := json.Unmarshal(upRec.Body.Bytes(), &uploaded); err != nil { t.Fatalf("unmarshal upload: %v", err) } if uploaded.Table == "" || uploaded.Rows <= 0 { t.Fatalf("upload returned empty table or zero rows: %+v", uploaded) } // Now GET /api/table should return the same table. getReq2 := httptest.NewRequest(http.MethodGet, "/api/table", nil) getRec2 := httptest.NewRecorder() handler.ServeHTTP(getRec2, getReq2) if getRec2.Code != http.StatusOK { t.Fatalf("follow-up GET /api/table: expected 200, got %d", getRec2.Code) } var got struct { Table string `json:"table"` Rows int `json:"rows"` } if err := json.Unmarshal(getRec2.Body.Bytes(), &got); err != nil { t.Fatalf("unmarshal follow-up: %v", err) } if got.Table != uploaded.Table { t.Errorf("GET /api/table table mismatch with upload response:\n upload=%q\n get=%q", uploaded.Table, got.Table) } if got.Rows != uploaded.Rows { t.Errorf("GET /api/table rows=%d, expected %d", got.Rows, uploaded.Rows) } } // TestUploadRendersPortugueseHeaders is the end-to-end integration check for // Chunk 4 of the webapp plan: upload trading212-small.csv through the live // HTTP handler and confirm the rendered table contains the Portuguese // header text emitted by the backend's pt localizer. The header text is // uppercased by go-pretty's StyleLight, so the asserted substring is // "REALIZAÇÃO" (with the diacritic preserved). func TestUploadRendersPortugueseHeaders(t *testing.T) { handler := newTestServer(t) body, ct, _ := multipartUpload(t, "../../trading212-small.csv") req := httptest.NewRequest(http.MethodPost, "/api/upload", body) req.Header.Set("Content-Type", ct) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d, body=%q", rec.Code, rec.Body.String()) } var resp struct { Table string `json:"table"` Rows int `json:"rows"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v, body=%q", err, rec.Body.String()) } if resp.Table == "" { t.Fatalf("expected non-empty rendered table") } if resp.Rows <= 0 { t.Fatalf("expected rows > 0, got %d", resp.Rows) } // The pt localizer supplies these labels and go-pretty uppercases the // header cells, so we assert the uppercased Portuguese form. for _, want := range []string{"REALIZAÇÃO", "AQUISIÇÃO", "PAÍS DA FONTE"} { if !strings.Contains(resp.Table, want) { t.Errorf("expected rendered table to contain header %q; got:\n%s", want, resp.Table) } } } // TestNatureG20ForETFStub confirms that when the stub returns securityType // "ETP", sell rows in the rendered table carry the G20 nature code, not // G01. This pins down the OpenFIGI stub misclassification fix: production // uses a real OpenFIGI client (which returns "ETP" for ETFs like IDVY), // while this test uses a stub that hard-codes "ETP" so the same mapping // is exercised without network calls. func TestNatureG20ForETFStub(t *testing.T) { handler := newTestServer(t) body, ct, _ := multipartUpload(t, "../../trading212-small.csv") req := httptest.NewRequest(http.MethodPost, "/api/upload", body) req.Header.Set("Content-Type", ct) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d, body=%q", rec.Code, rec.Body.String()) } var resp struct { Table string `json:"table"` Rows int `json:"rows"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v, body=%q", err, rec.Body.String()) } if resp.Rows <= 0 { t.Fatalf("expected rows > 0, got %d", resp.Rows) } // The stub returns "ETP" -> G20, so the rendered table must contain // "G20". It must NOT contain "G01" for any sell row of an ETF symbol. if !strings.Contains(resp.Table, "G20") { t.Errorf("expected rendered table to contain nature code G20 (ETP); got:\n%s", resp.Table) } } func TestGetServesIndex(t *testing.T) { handler := newTestServer(t) req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("GET /: expected 200, got %d", rec.Code) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Errorf("GET /: expected text/html content-type, got %q", ct) } if !strings.Contains(rec.Body.String(), "