diff --git a/cmd/any2anexoj-cli/main.go b/cmd/any2anexoj-cli/main.go index 340120a..4de4204 100644 --- a/cmd/any2anexoj-cli/main.go +++ b/cmd/any2anexoj-cli/main.go @@ -12,8 +12,10 @@ import ( "github.com/nmoniz/any2anexoj/internal" "github.com/nmoniz/any2anexoj/internal/ofigi" - "github.com/nmoniz/any2anexoj/internal/prettyprinter" "github.com/nmoniz/any2anexoj/internal/trading212" + "github.com/nmoniz/any2anexoj/internal/writing" + "github.com/nmoniz/any2anexoj/internal/writing/csv" + "github.com/nmoniz/any2anexoj/internal/writing/pretty" "github.com/spf13/pflag" "golang.org/x/text/language" ) @@ -99,13 +101,13 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error { switch *format { case "csv": - return NewCSVWriter(stdout).Render(writer) + return csv.NewCSVWriter(stdout).Render(writer) case "table": - loc, err := prettyprinter.NewLocalizer(*lang) + loc, err := writing.NewLocalizer(*lang) if err != nil { return fmt.Errorf("create localizer: %w", err) } - prettyprinter.NewPrettyPrinter(stdout, loc).Render(writer) + pretty.NewPrettyPrinter(stdout, loc).Render(writer) return nil default: return fmt.Errorf("unsupported format %q: must be table or csv", *format) diff --git a/internal/web/auth.go b/internal/web/auth.go deleted file mode 100644 index 0f27319..0000000 --- a/internal/web/auth.go +++ /dev/null @@ -1,11 +0,0 @@ -package web - -import "net/http" - -// AuthMiddleware is a placeholder seam for future authentication. For now it -// is a pass-through that invokes the wrapped handler unchanged. -func AuthMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - next.ServeHTTP(w, r) - }) -} diff --git a/internal/web/handlers.go b/internal/web/handlers.go deleted file mode 100644 index e1d320a..0000000 --- a/internal/web/handlers.go +++ /dev/null @@ -1,167 +0,0 @@ -package web - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - - "github.com/nmoniz/any2anexoj/internal" - "github.com/nmoniz/any2anexoj/internal/prettyprinter" - "github.com/nmoniz/any2anexoj/internal/trading212" -) - -// MaxUploadSize caps the size of the multipart upload. Trading212 CSV exports -// for an active account tend to be a few MB; 32 MiB is generous while still -// preventing abuse. -const MaxUploadSize = 32 << 20 - -// tableResponse is the JSON shape returned by both the upload and table -// endpoints. -type tableResponse struct { - Table string `json:"table"` - Rows int `json:"rows"` -} - -// errorResponse is the JSON shape returned for all 4xx/5xx responses. -type errorResponse struct { - Error string `json:"error"` -} - -// writeJSON marshals v as JSON and writes it with the given status code. Any -// encoding error is returned so the caller can decide how to surface it. -func writeJSON(w http.ResponseWriter, status int, v any) error { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - return json.NewEncoder(w).Encode(v) -} - -// writeError writes a JSON {"error": "..."} response. -func writeError(w http.ResponseWriter, status int, msg string) error { - return writeJSON(w, status, errorResponse{Error: msg}) -} - -// ClientError tags an error that originates from the uploaded CSV (bad -// formatting, missing fields, malformed values, etc.). The upload handler -// uses it to map these to HTTP 400 instead of HTTP 500: a malformed CSV is a -// client mistake, not a server problem. -type ClientError struct { - Err error -} - -func (e *ClientError) Error() string { return e.Err.Error() } -func (e *ClientError) Unwrap() error { return e.Err } - -// csvRecordReader wraps a trading212 RecordReader so every error from -// ReadRecord is tagged as a *ClientError. This lets the handler distinguish -// "the CSV is bad" from "the server is broken" via errors.As. The Unwrap -// method on *ClientError preserves errors.Is(err, io.EOF) for BuildReport's -// end-of-stream detection. -type csvRecordReader struct { - inner internal.RecordReader -} - -func (w *csvRecordReader) ReadRecord(ctx context.Context) (internal.Record, error) { - rec, err := w.inner.ReadRecord(ctx) - if err != nil { - return nil, &ClientError{Err: err} - } - return rec, nil -} - -// handleUpload parses a multipart form upload, runs the report pipeline, and -// stores the rendered table for retrieval via GET /api/table. -func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { - if err := r.ParseMultipartForm(MaxUploadSize); err != nil { - // Malformed multipart bodies (wrong Content-Type, truncated body, - // missing boundary, etc.) all land here. - if writeErr := writeError(w, http.StatusBadRequest, fmt.Sprintf("failed to parse multipart form: %v", err)); writeErr != nil { - http.Error(w, writeErr.Error(), http.StatusInternalServerError) - } - return - } - - file, _, err := r.FormFile("csv") - if err != nil { - if errors.Is(err, http.ErrMissingFile) { - if writeErr := writeError(w, http.StatusBadRequest, "missing csv file"); writeErr != nil { - http.Error(w, writeErr.Error(), http.StatusInternalServerError) - } - return - } - if writeErr := writeError(w, http.StatusBadRequest, fmt.Sprintf("failed to read csv file: %v", err)); writeErr != nil { - http.Error(w, writeErr.Error(), http.StatusInternalServerError) - } - return - } - defer file.Close() - - table, rows, buildErr := s.buildRenderedTable(r.Context(), file) - if buildErr != nil { - // CSV parse failures (tagged as *ClientError by csvRecordReader) are - // user mistakes and should surface as HTTP 400. Anything else - - // internal.BuildReport failures, localizer creation failures, etc. - - // is a server problem and gets HTTP 500. - var clientErr *ClientError - if errors.As(buildErr, &clientErr) { - if writeErr := writeError(w, http.StatusBadRequest, clientErr.Error()); writeErr != nil { - http.Error(w, writeErr.Error(), http.StatusInternalServerError) - } - return - } - if writeErr := writeError(w, http.StatusInternalServerError, buildErr.Error()); writeErr != nil { - http.Error(w, writeErr.Error(), http.StatusInternalServerError) - } - return - } - - s.state.Set(table, rows) - - if err := writeJSON(w, http.StatusOK, tableResponse{Table: table, Rows: rows}); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } -} - -// handleGetTable returns the most recently rendered table. When no upload has -// happened yet the response contains an empty table string and zero rows. -func (s *Server) handleGetTable(w http.ResponseWriter, _ *http.Request) { - table, rows := s.state.Get() - if err := writeJSON(w, http.StatusOK, tableResponse{Table: table, Rows: rows}); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } -} - -// buildRenderedTable wires the CSV reader, the aggregator, BuildReport, and the -// pretty-printer into a single pipeline. It returns the rendered table string -// and the row count. -func (s *Server) buildRenderedTable(ctx context.Context, r io.Reader) (string, int, error) { - innerReader := trading212.NewRecordReader(r, s.figiClient) - reader := &csvRecordReader{inner: innerReader} - writer := internal.NewAggregatorWriter() - - if err := internal.BuildReport(ctx, reader, writer, internal.WithSelector(internal.Any())); err != nil { - return "", 0, fmt.Errorf("failed to build report: %w", err) - } - - loc, err := prettyprinter.NewLocalizer("pt") - if err != nil { - return "", 0, fmt.Errorf("failed to create localizer: %w", err) - } - - var buf bytes.Buffer - prettyprinter.NewPrettyPrinter(&buf, loc).Render(writer) - - // Row count is the number of report items the aggregator ended up with, - // since each item becomes a row in the rendered table. - rows := 0 - for range writer.Iter() { - rows++ - } - - return buf.String(), rows, nil -} diff --git a/internal/web/server.go b/internal/web/server.go deleted file mode 100644 index cd7a39e..0000000 --- a/internal/web/server.go +++ /dev/null @@ -1,82 +0,0 @@ -package web - -import ( - "fmt" - "io/fs" - "net/http" - "time" - - "github.com/nmoniz/any2anexoj/internal/ofigi" -) - -// Server wires the HTTP handlers, embedded static assets, and shared state -// behind a single http.Handler. Use NewServer to construct one. -type Server struct { - state *State - router *http.ServeMux - staticFS fs.FS - indexHTML []byte - figiClient *ofigi.Client -} - -// Option mutates a Server during construction. Use WithFigiClient (mainly in -// tests) to inject a custom OpenFIGI client so tests can run offline against -// a stub while production uses a real network client. -type Option func(*Server) - -// WithFigiClient overrides the OpenFIGI client used by the report pipeline. -// Production should leave this unset so NewServer constructs a real client -// (no API key, 5 s timeout) that gracefully falls back to NatureUnknown -// when OpenFIGI calls fail. Tests pass a stub backed by an httptest-style -// transport to keep the test suite offline and deterministic. -func WithFigiClient(c *ofigi.Client) Option { - return func(s *Server) { - s.figiClient = c - } -} - -// NewServer constructs a Server with all routes registered. The staticFS is -// the embedded subtree rooted at the directory that should be served under -// /static/* (i.e. it must contain an "index.html" file). The returned -// Server's Handler() method exposes the configured http.Handler. -func NewServer(state *State, staticFS fs.FS, opts ...Option) (*Server, error) { - indexBytes, err := fs.ReadFile(staticFS, "index.html") - if err != nil { - return nil, fmt.Errorf("web: missing index.html in static FS: %w", err) - } - - s := &Server{ - state: state, - router: http.NewServeMux(), - staticFS: staticFS, - indexHTML: indexBytes, - figiClient: ofigi.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second}, ""), - } - for _, opt := range opts { - opt(s) - } - s.routes() - return s, nil -} - -// Handler returns the configured HTTP handler, wrapped in the auth middleware -// stub so future auth wiring only has to change auth.go. -func (s *Server) Handler() http.Handler { - return AuthMiddleware(s.router) -} - -// routes registers the HTTP routes handled by this server. -func (s *Server) routes() { - // API routes. - s.router.HandleFunc("POST /api/upload", s.handleUpload) - s.router.HandleFunc("GET /api/table", s.handleGetTable) - - // Serve the embedded static assets under /static/*. - s.router.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(s.staticFS)))) - - // Serve the embedded index page at "/". - s.router.HandleFunc("GET /{$}", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write(s.indexHTML) - }) -} diff --git a/internal/web/server_test.go b/internal/web/server_test.go deleted file mode 100644 index 8c211fb..0000000 --- a/internal/web/server_test.go +++ /dev/null @@ -1,395 +0,0 @@ -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(), " -
test fixture diff --git a/cmd/any2anexoj-cli/csv_writer.go b/internal/writing/csv/writer.go similarity index 92% rename from cmd/any2anexoj-cli/csv_writer.go rename to internal/writing/csv/writer.go index 9f31464..3468d47 100644 --- a/cmd/any2anexoj-cli/csv_writer.go +++ b/internal/writing/csv/writer.go @@ -1,4 +1,6 @@ -package main +// Package csv renders an internal.AggregatorWriter as the CSV schema +// expected by Modelo 3 do Anexo J. +package csv import ( "encoding/csv" diff --git a/internal/writing/csv/writer_test.go b/internal/writing/csv/writer_test.go new file mode 100644 index 0000000..bb4e087 --- /dev/null +++ b/internal/writing/csv/writer_test.go @@ -0,0 +1,78 @@ +package csv_test + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/nmoniz/any2anexoj/internal" + "github.com/nmoniz/any2anexoj/internal/writing/csv" + "github.com/shopspring/decimal" +) + +func TestCSVWriter_Render(t *testing.T) { + aw := internal.NewAggregatorWriter() + ctx := t.Context() + + items := []internal.ReportItem{ + { + Symbol: "AAPL", + Nature: internal.NatureG01, + BrokerCountry: 826, // United Kingdom + AssetCountry: 840, // United States + BuyValue: decimal.NewFromFloat(100.50), + BuyTimestamp: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC), + SellValue: decimal.NewFromFloat(150.75), + SellTimestamp: time.Date(2023, 6, 20, 0, 0, 0, 0, time.UTC), + Fees: decimal.NewFromFloat(2.50), + Taxes: decimal.NewFromFloat(5.00), + }, + { + Symbol: "VWRL", + Nature: internal.NatureG20, + BrokerCountry: 196, // Portugal equivalent demo + AssetCountry: 372, // Ireland + BuyValue: decimal.NewFromFloat(200.00), + BuyTimestamp: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), + SellValue: decimal.NewFromFloat(250.25), + SellTimestamp: time.Date(2024, 7, 10, 0, 0, 0, 0, time.UTC), + Fees: decimal.NewFromFloat(1.00), + Taxes: decimal.NewFromFloat(0.00), + }, + } + + for _, ri := range items { + if err := aw.Write(ctx, ri); err != nil { + t.Fatalf("write report item: %v", err) + } + } + + var buf bytes.Buffer + if err := csv.NewCSVWriter(&buf).Render(aw); err != nil { + t.Fatalf("render: %v", err) + } + + out := buf.String() + + wantHeader := "source_country,code,realization_year,realization_month,realization_day,realization_value,acquisition_year,acquisition_month,acquisition_day,acquisition_value,expenses,foreign_tax_paid,counter_country" + if !strings.HasPrefix(out, wantHeader+"\n") { + t.Errorf("expected header row %q, got output:\n%s", wantHeader, out) + } + + wantRow1 := "840,G01,2023,6,20,150.75,2023,1,15,100.50,2.50,5.00,826" + if !strings.Contains(out, wantRow1+"\n") { + t.Errorf("expected row 1 %q in output:\n%s", wantRow1, out) + } + + wantRow2 := "372,G20,2024,7,10,250.25,2024,2,1,200.00,1.00,0.00,196" + if !strings.Contains(out, wantRow2+"\n") { + t.Errorf("expected row 2 %q in output:\n%s", wantRow2, out) + } + + // Make sure the header only appears once and we have exactly 1 header + 2 data rows. + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if got, want := len(lines), 3; got != want { + t.Errorf("expected %d lines (1 header + 2 rows), got %d:\n%s", want, got, out) + } +} diff --git a/internal/prettyprinter/localizer.go b/internal/writing/localizer.go similarity index 98% rename from internal/prettyprinter/localizer.go rename to internal/writing/localizer.go index 74318ae..c7204ea 100644 --- a/internal/prettyprinter/localizer.go +++ b/internal/writing/localizer.go @@ -1,4 +1,4 @@ -package prettyprinter +package writing import ( "embed" diff --git a/internal/prettyprinter/localizer_test.go b/internal/writing/localizer_test.go similarity index 77% rename from internal/prettyprinter/localizer_test.go rename to internal/writing/localizer_test.go index 1f16297..854ef31 100644 --- a/internal/prettyprinter/localizer_test.go +++ b/internal/writing/localizer_test.go @@ -1,9 +1,9 @@ -package prettyprinter_test +package writing_test import ( "testing" - "github.com/nmoniz/any2anexoj/internal/prettyprinter" + "github.com/nmoniz/any2anexoj/internal/writing" ) func TestNewLocalizer(t *testing.T) { @@ -19,7 +19,7 @@ func TestNewLocalizer(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := prettyprinter.NewLocalizer(tt.lang) + _, err := writing.NewLocalizer(tt.lang) if err != nil { t.Fatalf("want success call but failed: %v", err) } diff --git a/internal/prettyprinter/printer.go b/internal/writing/pretty/printer.go similarity index 65% rename from internal/prettyprinter/printer.go rename to internal/writing/pretty/printer.go index 7012cca..65a0b10 100644 --- a/internal/prettyprinter/printer.go +++ b/internal/writing/pretty/printer.go @@ -1,4 +1,4 @@ -package prettyprinter +package pretty import ( "fmt" @@ -17,16 +17,27 @@ type translator interface { } // PrettyPrinter writes a simple, human readable, table row to the provided io.Writer for each -// ReportItem received. +// ReportItem received. Render and RenderHTML each build a fresh table.Writer so that +// invoking them (in any order or repeatedly) on the same instance never duplicates rows. type PrettyPrinter struct { - table table.Writer output io.Writer translator translator } func NewPrettyPrinter(w io.Writer, tr translator) *PrettyPrinter { + return &PrettyPrinter{ + output: w, + translator: tr, + } +} + +// buildTable constructs a fully configured table.Writer from the items in aw. +// Each call returns a new table.Writer so callers can render the same data +// multiple times without duplicating rows. The returned table is not wired to +// pp.output via an output mirror; callers that want the rendered output +// written to pp.output must set the mirror themselves before calling .Render(). +func (pp *PrettyPrinter) buildTable(aw *internal.AggregatorWriter) table.Writer { tw := table.NewWriter() - tw.SetOutputMirror(w) tw.SetAutoIndex(true) tw.SetStyle(table.StyleLight) tw.SetColumnConfigs([]table.ColumnConfig{ @@ -45,14 +56,6 @@ func NewPrettyPrinter(w io.Writer, tr translator) *PrettyPrinter { colCountry(13), }) - return &PrettyPrinter{ - table: tw, - output: w, - translator: tr, - } -} - -func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) { realizationTxt := pp.translator.Translate("realization", 1, nil) acquisitionTxt := pp.translator.Translate("acquisition", 1, nil) yearTxt := pp.translator.Translate("year", 1, nil) @@ -60,8 +63,8 @@ func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) { dayTxt := pp.translator.Translate("day", 1, nil) valorTxt := pp.translator.Translate("value", 1, nil) - pp.table.AppendHeader(table.Row{"", "", realizationTxt, realizationTxt, realizationTxt, realizationTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, "", "", ""}, table.RowConfig{AutoMerge: true}) - pp.table.AppendHeader(table.Row{ + tw.AppendHeader(table.Row{"", "", realizationTxt, realizationTxt, realizationTxt, realizationTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, "", "", ""}, table.RowConfig{AutoMerge: true}) + tw.AppendHeader(table.Row{ pp.translator.Translate("source_country", 1, nil), pp.translator.Translate("code", 1, nil), yearTxt, monthTxt, dayTxt, valorTxt, yearTxt, monthTxt, dayTxt, valorTxt, @@ -69,7 +72,7 @@ func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) { }) for ri := range aw.Iter() { - pp.table.AppendRow(table.Row{ + tw.AppendRow(table.Row{ ri.AssetCountry, ri.Nature, ri.SellTimestamp.Year(), int(ri.SellTimestamp.Month()), ri.SellTimestamp.Day(), ri.SellValue.StringFixed(2), ri.BuyTimestamp.Year(), int(ri.BuyTimestamp.Month()), ri.BuyTimestamp.Day(), ri.BuyValue.StringFixed(2), @@ -77,8 +80,29 @@ func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) { }) } - pp.table.AppendFooter(table.Row{"SUM", "SUM", "SUM", "SUM", "SUM", aw.TotalEarned(), "", "", "", aw.TotalSpent(), aw.TotalFees(), aw.TotalTaxes()}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight}) - pp.table.Render() + sumTxt := pp.translator.Translate("sum", 1, nil) + tw.AppendFooter(table.Row{sumTxt, sumTxt, sumTxt, sumTxt, sumTxt, aw.TotalEarned(), "", "", "", aw.TotalSpent(), aw.TotalFees(), aw.TotalTaxes()}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight}) + + return tw +} + +func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) { + t := pp.buildTable(aw) + t.SetOutputMirror(pp.output) + t.Render() +} + +// RenderHTML builds a fresh table from aw and renders it as an HTML string +// using go-pretty's HTMLOptions. +func (pp *PrettyPrinter) RenderHTML(aw *internal.AggregatorWriter) string { + t := pp.buildTable(aw) + t.Style().HTML = table.HTMLOptions{ + CSSClass: "table table-striped table-hover table-bordered", + EscapeText: true, + Newline: "