initial webapp

This commit is contained in:
natercio committed 2026-09-28 21:46:54 +01:00
1 parent 6c989c338c
commit d32f2b3a66
18 files changed
+1145 -16

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
package prettyprinter
import (
"embed"
"encoding/json"
"fmt"
"log/slog"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
)
//go:embed translations/*.json
var translationsFS embed.FS
type Localizer struct {
*i18n.Localizer
}
func NewLocalizer(lang string) (*Localizer, error) {
bundle := i18n.NewBundle(language.English)
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
_, err := bundle.LoadMessageFileFS(translationsFS, "translations/en.json")
if err != nil {
return nil, fmt.Errorf("loading english messages: %w", err)
}
_, err = bundle.LoadMessageFileFS(translationsFS, "translations/pt.json")
if err != nil {
return nil, fmt.Errorf("loading portuguese messages: %w", err)
}
localizer := i18n.NewLocalizer(bundle, lang)
return &Localizer{
Localizer: localizer,
}, nil
}
func (t Localizer) Translate(key string, count int, values map[string]any) string {
txt, err := t.Localize(&i18n.LocalizeConfig{
MessageID: key,
TemplateData: values,
PluralCount: count,
})
if err != nil {
slog.Error("failed to translate message", slog.Any("err", err))
return "<ERROR>"
}
return txt
}
+28
View File
@@ -0,0 +1,28 @@
package prettyprinter_test
import (
"testing"
"github.com/nmoniz/any2anexoj/internal/prettyprinter"
)
func TestNewLocalizer(t *testing.T) {
tests := []struct {
name string
lang string
}{
{"english", "en"},
{"portuguese", "pt"},
{"english with region", "en-US"},
{"portuguese with region", "pt-BR"},
{"unknown language falls back to default", "!!"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := prettyprinter.NewLocalizer(tt.lang)
if err != nil {
t.Fatalf("want success call but failed: %v", err)
}
})
}
}
+124
View File
@@ -0,0 +1,124 @@
package prettyprinter
import (
"fmt"
"io"
"github.com/biter777/countries"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/nmoniz/any2anexoj/internal"
)
// translator is the contract the pretty printer relies on for human-readable
// column labels. *Localizer satisfies it.
type translator interface {
Translate(key string, count int, values map[string]any) string
}
// PrettyPrinter writes a simple, human readable, table row to the provided io.Writer for each
// ReportItem received.
type PrettyPrinter struct {
table table.Writer
output io.Writer
translator translator
}
func NewPrettyPrinter(w io.Writer, tr translator) *PrettyPrinter {
tw := table.NewWriter()
tw.SetOutputMirror(w)
tw.SetAutoIndex(true)
tw.SetStyle(table.StyleLight)
tw.SetColumnConfigs([]table.ColumnConfig{
colCountry(1),
colOther(2),
colOther(3),
colOther(4),
colOther(5),
colEuros(6),
colOther(7),
colOther(8),
colOther(9),
colEuros(10),
colEuros(11),
colEuros(12),
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)
monthTxt := pp.translator.Translate("month", 1, nil)
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{
pp.translator.Translate("source_country", 1, nil), pp.translator.Translate("code", 1, nil),
yearTxt, monthTxt, dayTxt, valorTxt,
yearTxt, monthTxt, dayTxt, valorTxt,
pp.translator.Translate("expenses", 2, nil), pp.translator.Translate("foreign_tax_paid", 1, nil), pp.translator.Translate("counter_country", 1, nil),
})
for ri := range aw.Iter() {
pp.table.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),
ri.Fees.StringFixed(2), ri.Taxes.StringFixed(2), ri.BrokerCountry,
})
}
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()
}
func colEuros(n int) table.ColumnConfig {
return table.ColumnConfig{
Number: n,
Align: text.AlignRight,
AlignFooter: text.AlignRight,
AlignHeader: text.AlignRight,
WidthMin: 12,
WidthMax: 15,
Transformer: func(val any) string {
return fmt.Sprintf("%v €", val)
},
TransformerFooter: func(val any) string {
return fmt.Sprintf("%v €", val)
},
}
}
func colOther(n int) table.ColumnConfig {
return table.ColumnConfig{
Number: n,
Align: text.AlignLeft,
AlignFooter: text.AlignLeft,
AlignHeader: text.AlignLeft,
WidthMax: 12,
}
}
func colCountry(n int) table.ColumnConfig {
return table.ColumnConfig{
Number: n,
Align: text.AlignLeft,
AlignFooter: text.AlignLeft,
AlignHeader: text.AlignLeft,
WidthMax: 24,
WidthMaxEnforcer: text.Trim,
Transformer: func(val any) string {
countryCode := val.(int64)
return fmt.Sprintf("%v - %s", val, countries.ByNumeric(int(countryCode)).Info().Name)
},
}
}
+84
View File
@@ -0,0 +1,84 @@
package prettyprinter_test
import (
"bytes"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/prettyprinter"
"github.com/shopspring/decimal"
)
func TestPrettyPrinter_Render(t *testing.T) {
// Create test data
aw := internal.NewAggregatorWriter()
ctx := t.Context()
// Add some sample report items
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 first report item: %v", err)
}
err = aw.Write(ctx, internal.ReportItem{
Symbol: "GOOGL",
Nature: internal.NatureG20,
BrokerCountry: 826, // United Kingdom
AssetCountry: 840, // United States
BuyValue: decimal.NewFromFloat(200.00),
BuyTimestamp: time.Date(2023, 3, 10, 0, 0, 0, 0, time.UTC),
SellValue: decimal.NewFromFloat(225.50),
SellTimestamp: time.Date(2023, 9, 5, 0, 0, 0, 0, time.UTC),
Fees: decimal.NewFromFloat(3.00),
Taxes: decimal.NewFromFloat(7.50),
})
if err != nil {
t.Fatalf("failed to write second report item: %v", err)
}
// Create English localizer
localizer, err := prettyprinter.NewLocalizer("en")
if err != nil {
t.Fatalf("failed to create localizer: %v", err)
}
// Create pretty printer with buffer
var buf bytes.Buffer
pp := prettyprinter.NewPrettyPrinter(&buf, localizer)
// Render the table
pp.Render(aw)
// Get the output
got := buf.String()
// Expected output
want := `┌───┬────────────────────────────┬───────────────────────────────────┬───────────────────────────────────┬──────────────────────────────────────────────────────────┐
│ │ │ REALIZATION │ ACQUISITION │ │
│ │ SOURCE COUNTRY │ CODE │ YEAR │ MONTH │ DAY │ VALUE │ YEAR │ MONTH │ DAY │ VALUE │ EXPENSES AND CH │ TAX PAID ABROAD │ COUNTER COUNTRY │
│ │ │ │ │ │ │ │ │ │ │ │ ARGES │ │ │
├───┼─────────────────────┼──────┼──────┼───────┼─────┼──────────────┼──────┼───────┼─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤
│ 1 │ 840 - United States │ G01 │ 2023 │ 6 │ 20 │ 150.75 € │ 2023 │ 1 │ 15 │ 100.50 € │ 2.50 € │ 5.00 € │ 826 - United Kingdom │
│ 2 │ 840 - United States │ G20 │ 2023 │ 9 │ 5 │ 225.50 € │ 2023 │ 3 │ 10 │ 200.00 € │ 3.00 € │ 7.50 € │ 826 - United Kingdom │
├───┼─────────────────────┴──────┴──────┴───────┴─────┼──────────────┼──────┴───────┴─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤
│ │ SUM │ 376.25 € │ │ 300.5 € │ 5.5 € │ 12.5 € │ │
└───┴─────────────────────────────────────────────────┴──────────────┴────────────────────┴──────────────┴─────────────────┴─────────────────┴──────────────────────┘
`
// Compare output
if got != want {
t.Errorf("PrettyPrinter.Render() output doesn't match expected.\n\nGot:\n%s\n\nWant:\n%s", got, want)
}
}
@@ -0,0 +1,46 @@
{
"realization": {
"one": "Realization",
"other": "Realizations"
},
"acquisition": {
"one": "Acquisition",
"other": "Acquisitions"
},
"source_country": {
"one": "Source country",
"other": "Source countries"
},
"counter_country": {
"one": "Counter country",
"other": "Counter countries"
},
"year": {
"one": "Year",
"other": "Years"
},
"month": {
"one": "Month",
"other": "Months"
},
"day": {
"one": "Day",
"other": "Days"
},
"value": {
"one": "Value",
"other": "Values"
},
"code": {
"one": "Code",
"other": "Codes"
},
"expenses": {
"one": "Expense and charge",
"other": "Expenses and charges"
},
"foreign_tax_paid": {
"one": "Tax paid abroad",
"other": "Taxes paid abroad"
}
}
@@ -0,0 +1,46 @@
{
"realization": {
"one": "Realização",
"other": "Realizações"
},
"acquisition": {
"one": "Aquisição",
"other": "Aquisições"
},
"source_country": {
"one": "País da fonte",
"other": "Países da fonte"
},
"counter_country": {
"one": "País da contraparte",
"other": "Países da contraparte"
},
"year": {
"one": "Ano",
"other": "Anos"
},
"month": {
"one": "Mês",
"other": "Meses"
},
"day": {
"one": "Dia",
"other": "Dias"
},
"value": {
"one": "Valor",
"other": "Valores"
},
"code": {
"one": "Código",
"other": "Códigos"
},
"expenses": {
"one": "Despesa e encargo",
"other": "Despesas e encargos"
},
"foreign_tax_paid": {
"one": "Imposto pago no estrangeiro",
"other": "Impostos pagos no estrangeiro"
}
}
+11
View File
@@ -0,0 +1,11 @@
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)
})
}
+167
View File
@@ -0,0 +1,167 @@
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
}
+82
View File
@@ -0,0 +1,82 @@
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)
})
}
+395
View File
@@ -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())
}
}
+33
View File
@@ -0,0 +1,33 @@
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
}
+2
View File
@@ -0,0 +1,2 @@
<!DOCTYPE html>
<html><body>test fixture</body></html>