initial webapp
This commit is contained in:
1 parent
6c989c338c
commit
d32f2b3a66
18 files changed
+1145
-16
No files matched your search
@@ -0,0 +1,395 @@
|
||||
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(), "<html") {
|
||||
t.Errorf("GET /: expected HTML body, got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user