writers in writing package
This commit is contained in:
1 parent
4604175e8d
commit
bb00676452
15 files changed
+215
-721
No files matched your search
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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(), "<html") {
|
||||
t.Errorf("GET /: expected HTML body, got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package web
|
||||
|
||||
import "sync"
|
||||
|
||||
// State holds the last rendered report table and row count produced by the
|
||||
// upload handler. The web app is single-user and in-memory, but net/http runs
|
||||
// handlers concurrently, so reads and writes are guarded by an RWMutex.
|
||||
type State struct {
|
||||
mu sync.RWMutex
|
||||
table string
|
||||
rows int
|
||||
}
|
||||
|
||||
// NewState returns an empty State with no rendered table.
|
||||
func NewState() *State {
|
||||
return &State{}
|
||||
}
|
||||
|
||||
// Set replaces the stored rendered table string and row count.
|
||||
func (s *State) Set(table string, rows int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.table = table
|
||||
s.rows = rows
|
||||
}
|
||||
|
||||
// Get returns the current rendered table string and row count. The returned
|
||||
// values are zero-valued when no upload has happened yet.
|
||||
func (s *State) Get() (string, int) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.table, s.rows
|
||||
}
|
||||
Vendored
-2
@@ -1,2 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html><body>test fixture</body></html>
|
||||
@@ -0,0 +1,59 @@
|
||||
// Package csv renders an internal.AggregatorWriter as the CSV schema
|
||||
// expected by Modelo 3 do Anexo J.
|
||||
package csv
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
)
|
||||
|
||||
type CSVWriter struct {
|
||||
w *csv.Writer
|
||||
}
|
||||
|
||||
func NewCSVWriter(w io.Writer) *CSVWriter {
|
||||
return &CSVWriter{w: csv.NewWriter(w)}
|
||||
}
|
||||
|
||||
func (cw *CSVWriter) Render(aw *internal.AggregatorWriter) error {
|
||||
err := cw.w.Write([]string{
|
||||
"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 err != nil {
|
||||
return fmt.Errorf("write csv header: %w", err)
|
||||
}
|
||||
|
||||
for ri := range aw.Iter() {
|
||||
err := cw.w.Write(reportItemToRow(ri))
|
||||
if err != nil {
|
||||
return fmt.Errorf("write csv row: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
cw.w.Flush()
|
||||
return cw.w.Error()
|
||||
}
|
||||
|
||||
func reportItemToRow(ri internal.ReportItem) []string {
|
||||
return []string{
|
||||
fmt.Sprintf("%d", ri.AssetCountry),
|
||||
string(ri.Nature),
|
||||
fmt.Sprintf("%d", ri.SellTimestamp.Year()),
|
||||
fmt.Sprintf("%d", int(ri.SellTimestamp.Month())),
|
||||
fmt.Sprintf("%d", ri.SellTimestamp.Day()),
|
||||
ri.SellValue.StringFixed(2),
|
||||
fmt.Sprintf("%d", ri.BuyTimestamp.Year()),
|
||||
fmt.Sprintf("%d", int(ri.BuyTimestamp.Month())),
|
||||
fmt.Sprintf("%d", ri.BuyTimestamp.Day()),
|
||||
ri.BuyValue.StringFixed(2),
|
||||
ri.Fees.StringFixed(2),
|
||||
ri.Taxes.StringFixed(2),
|
||||
fmt.Sprintf("%d", ri.BrokerCountry),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package prettyprinter
|
||||
package writing
|
||||
|
||||
import (
|
||||
"embed"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: "<br/>",
|
||||
EmptyColumn: " ",
|
||||
}
|
||||
return t.RenderHTML()
|
||||
}
|
||||
|
||||
func colEuros(n int) table.ColumnConfig {
|
||||
@@ -1,12 +1,14 @@
|
||||
package prettyprinter_test
|
||||
package pretty_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/prettyprinter"
|
||||
"github.com/nmoniz/any2anexoj/internal/writing"
|
||||
"github.com/nmoniz/any2anexoj/internal/writing/pretty"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
@@ -49,14 +51,14 @@ func TestPrettyPrinter_Render(t *testing.T) {
|
||||
}
|
||||
|
||||
// Create English localizer
|
||||
localizer, err := prettyprinter.NewLocalizer("en")
|
||||
localizer, err := writing.NewLocalizer("en")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create localizer: %v", err)
|
||||
}
|
||||
|
||||
// Create pretty printer with buffer
|
||||
// Create writing printer with buffer
|
||||
var buf bytes.Buffer
|
||||
pp := prettyprinter.NewPrettyPrinter(&buf, localizer)
|
||||
pp := pretty.NewPrettyPrinter(&buf, localizer)
|
||||
|
||||
// Render the table
|
||||
pp.Render(aw)
|
||||
@@ -82,3 +84,71 @@ func TestPrettyPrinter_Render(t *testing.T) {
|
||||
t.Errorf("PrettyPrinter.Render() output doesn't match expected.\n\nGot:\n%s\n\nWant:\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHTML(t *testing.T) {
|
||||
// Create test data
|
||||
aw := internal.NewAggregatorWriter()
|
||||
ctx := t.Context()
|
||||
|
||||
err := aw.Write(ctx, 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),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write report item: %v", err)
|
||||
}
|
||||
|
||||
// Create Portuguese localizer so the header text is in pt
|
||||
localizer, err := writing.NewLocalizer("pt")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create localizer: %v", err)
|
||||
}
|
||||
|
||||
// Output buffer used only to satisfy the constructor contract; RenderHTML
|
||||
// must not write to it.
|
||||
var buf bytes.Buffer
|
||||
pp := pretty.NewPrettyPrinter(&buf, localizer)
|
||||
|
||||
html := pp.RenderHTML(aw)
|
||||
|
||||
// RenderHTML must not side-write to pp.output.
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("RenderHTML should not write to pp.output, but got %d bytes: %q", buf.Len(), buf.String())
|
||||
}
|
||||
|
||||
// Structural assertions.
|
||||
wantSubstrings := []string{
|
||||
`<table class="table table-striped table-hover table-bordered">`,
|
||||
`</table>`,
|
||||
`<thead>`,
|
||||
`</thead>`,
|
||||
`<tbody>`,
|
||||
`</tbody>`,
|
||||
`<tfoot>`,
|
||||
`</tfoot>`,
|
||||
"Realização",
|
||||
"Aquisição",
|
||||
"Soma",
|
||||
}
|
||||
for _, s := range wantSubstrings {
|
||||
if !strings.Contains(html, s) {
|
||||
t.Errorf("RenderHTML output missing expected substring %q\nGot:\n%s", s, html)
|
||||
}
|
||||
}
|
||||
|
||||
// Calling RenderHTML twice on the same instance must not duplicate rows.
|
||||
html2 := pp.RenderHTML(aw)
|
||||
if strings.Count(html2, "<tr") != strings.Count(html, "<tr") {
|
||||
t.Errorf("expected second RenderHTML call to produce the same number of rows as the first\nFirst (%d <tr>):\n%s\nSecond (%d <tr>):\n%s",
|
||||
strings.Count(html, "<tr"), html,
|
||||
strings.Count(html2, "<tr"), html2)
|
||||
}
|
||||
}
|
||||
@@ -42,5 +42,9 @@
|
||||
"foreign_tax_paid": {
|
||||
"one": "Tax paid abroad",
|
||||
"other": "Taxes paid abroad"
|
||||
},
|
||||
"sum": {
|
||||
"one": "Sum",
|
||||
"other": "Sums"
|
||||
}
|
||||
}
|
||||
@@ -42,5 +42,9 @@
|
||||
"foreign_tax_paid": {
|
||||
"one": "Imposto pago no estrangeiro",
|
||||
"other": "Impostos pagos no estrangeiro"
|
||||
},
|
||||
"sum": {
|
||||
"one": "Soma",
|
||||
"other": "Somas"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user