From d32f2b3a6696b6a801a1ba4fab1f645c319c0586 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Thu, 10 Sep 2026 23:09:19 +0100 Subject: [PATCH] initial webapp --- README.md | 37 ++ cmd/any2anexoj-cli/main.go | 5 +- cmd/any2anexoj-web/main.go | 85 ++++ cmd/any2anexoj-web/static/app.js | 108 +++++ cmd/any2anexoj-web/static/index.html | 33 ++ cmd/any2anexoj-web/static/style.css | 168 ++++++++ .../prettyprinter}/localizer.go | 2 +- .../prettyprinter}/localizer_test.go | 10 +- .../prettyprinter/printer.go | 16 +- .../prettyprinter/printer_test.go | 7 +- .../prettyprinter}/translations/en.json | 0 .../prettyprinter}/translations/pt.json | 0 internal/web/auth.go | 11 + internal/web/handlers.go | 167 ++++++++ internal/web/server.go | 82 ++++ internal/web/server_test.go | 395 ++++++++++++++++++ internal/web/state.go | 33 ++ internal/web/testdata/index.html | 2 + 18 files changed, 1145 insertions(+), 16 deletions(-) create mode 100644 cmd/any2anexoj-web/main.go create mode 100644 cmd/any2anexoj-web/static/app.js create mode 100644 cmd/any2anexoj-web/static/index.html create mode 100644 cmd/any2anexoj-web/static/style.css rename {cmd/any2anexoj-cli => internal/prettyprinter}/localizer.go (98%) rename {cmd/any2anexoj-cli => internal/prettyprinter}/localizer_test.go (73%) rename cmd/any2anexoj-cli/pretty_printer.go => internal/prettyprinter/printer.go (93%) rename cmd/any2anexoj-cli/pretty_printer_test.go => internal/prettyprinter/printer_test.go (96%) rename {cmd/any2anexoj-cli => internal/prettyprinter}/translations/en.json (100%) rename {cmd/any2anexoj-cli => internal/prettyprinter}/translations/pt.json (100%) create mode 100644 internal/web/auth.go create mode 100644 internal/web/handlers.go create mode 100644 internal/web/server.go create mode 100644 internal/web/server_test.go create mode 100644 internal/web/state.go create mode 100644 internal/web/testdata/index.html diff --git a/README.md b/README.md index 8026a23..7ac5a19 100644 --- a/README.md +++ b/README.md @@ -53,3 +53,40 @@ State is only persisted once the input reaches EOF, so a crash or `Ctrl-C` befor All Euro values are rounded to cents (2 decimal places) but internal calculations use the statement values with full precision. There are no explicit rules or details about how to round Euro values in Anexo J. This application rounds according to `Portaria n.º 1180/2001, art. 2.º, alínea c) e d)` (Ministerial Order / Government Order) examples, which imply we should round to the 2nd decimal place by rounding up (ceiling) or down (floor) depending on whether the third decimal place is ≥ 5 or < 5, respectively. + +## Web app + +A minimal single-user web frontend is bundled in the `cmd/any2anexoj-web` binary. It reuses the existing `internal/trading212` parser, the `internal.BuildReport` pipeline, and the `internal/prettyprinter` formatter, so the rendered table is byte-for-byte identical to the CLI output. + +### Run + +```bash +go run ./cmd/any2anexoj-web +``` + +The server listens on `:8080` by default. Override the port with the `PORT` environment variable: + +```bash +PORT=9000 go run ./cmd/any2anexoj-web +``` + +Then open in a browser, pick a Trading212 CSV file, and click **Upload**. The rendered Anexo J table appears below the form along with the row count. + +### API + +The web app exposes two JSON endpoints. Auth is intentionally stubbed (a no-op middleware), and state is kept in process memory only — single user, no persistence. + +| Method | Path | Description | +|--------|----------------|---------------------------------------------------------------------------------------------------| +| POST | `/api/upload` | `multipart/form-data` with a `csv` file field. Returns `{"table": "", "rows": }`. | +| GET | `/api/table` | Returns the most recently rendered table (`{"table": "", "rows": 0}` if none uploaded yet). | + +Example: + +```bash +curl -F csv=@trading212-small.csv http://localhost:8080/api/upload +curl http://localhost:8080/api/table +``` + +> [!NOTE] +> The web app is single-user and in-memory. Uploads overwrite the previously stored table, and restarting the server clears all state. Auth is a stub — do not expose this service to the public internet without adding real authentication and per-user state first. diff --git a/cmd/any2anexoj-cli/main.go b/cmd/any2anexoj-cli/main.go index 3a46285..340120a 100644 --- a/cmd/any2anexoj-cli/main.go +++ b/cmd/any2anexoj-cli/main.go @@ -12,6 +12,7 @@ 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/spf13/pflag" "golang.org/x/text/language" @@ -100,11 +101,11 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error { case "csv": return NewCSVWriter(stdout).Render(writer) case "table": - loc, err := NewLocalizer(*lang) + loc, err := prettyprinter.NewLocalizer(*lang) if err != nil { return fmt.Errorf("create localizer: %w", err) } - NewPrettyPrinter(stdout, loc).Render(writer) + prettyprinter.NewPrettyPrinter(stdout, loc).Render(writer) return nil default: return fmt.Errorf("unsupported format %q: must be table or csv", *format) diff --git a/cmd/any2anexoj-web/main.go b/cmd/any2anexoj-web/main.go new file mode 100644 index 0000000..5aa84ff --- /dev/null +++ b/cmd/any2anexoj-web/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "embed" + "errors" + "io/fs" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/nmoniz/any2anexoj/internal/web" +) + +//go:embed all:static +var staticFS embed.FS + +func main() { + if err := run(); err != nil { + slog.Error("any2anexoj-web: fatal error", slog.Any("err", err)) + os.Exit(1) + } +} + +// run wires up the server, installs signal-driven cancellation, and blocks on +// the HTTP server until it stops. It is split out from main so tests can drive +// the same code paths if needed. +func run() error { + addr := os.Getenv("PORT") + if addr == "" { + addr = ":8080" + } + // A bare "8080" value is friendlier than the net.Listen-style ":8080". + if addr[0] != ':' { + addr = ":" + addr + } + + // Sub-mount the embedded FS so the static subtree is rooted at "" inside + // the web package (matches /static/...). + staticSub, err := fs.Sub(staticFS, "static") + if err != nil { + return err + } + + state := web.NewState() + srv, err := web.NewServer(state, staticSub) + if err != nil { + return err + } + + httpServer := &http.Server{ + Addr: addr, + Handler: srv.Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Kill, os.Interrupt, syscall.SIGTERM) + defer cancel() + + errCh := make(chan error, 1) + go func() { + slog.Info("any2anexoj-web: listening", slog.String("addr", addr)) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + return + } + errCh <- nil + }() + + select { + case <-ctx.Done(): + slog.Info("any2anexoj-web: shutting down") + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + if err := httpServer.Shutdown(shutdownCtx); err != nil { + return err + } + return nil + case err := <-errCh: + return err + } +} diff --git a/cmd/any2anexoj-web/static/app.js b/cmd/any2anexoj-web/static/app.js new file mode 100644 index 0000000..6df3e1f --- /dev/null +++ b/cmd/any2anexoj-web/static/app.js @@ -0,0 +1,108 @@ +(function () { + "use strict"; + + var UPLOAD_URL = "/api/upload"; + + function $(id) { + return document.getElementById(id); + } + + function setStatus(message) { + var status = $("status"); + status.textContent = message || ""; + } + + function setError(message) { + var error = $("error"); + error.textContent = message || ""; + } + + function clearResult() { + var result = $("result"); + var rowsInfo = $("rows-info"); + var tableOutput = $("table-output"); + rowsInfo.textContent = ""; + tableOutput.textContent = ""; + result.hidden = true; + } + + function showResult(rows, table) { + var result = $("result"); + var rowsInfo = $("rows-info"); + var tableOutput = $("table-output"); + rowsInfo.textContent = "Rows: " + rows; + tableOutput.textContent = table || ""; + result.hidden = false; + } + + function uploadFile(file) { + var formData = new FormData(); + formData.append("csv", file); + + return fetch(UPLOAD_URL, { + method: "POST", + body: formData + }).then(function (response) { + return response.json().then(function (data) { + return { ok: response.ok, status: response.status, data: data }; + }); + }); + } + + function handleSubmit(event) { + event.preventDefault(); + + setError(""); + clearResult(); + + var fileInput = $("csv"); + if (!fileInput.files || fileInput.files.length === 0) { + setError("Please choose a CSV file before uploading."); + return; + } + + var file = fileInput.files[0]; + var button = $("upload-button"); + button.disabled = true; + setStatus("Uploading " + file.name + "..."); + + uploadFile(file).then(function (result) { + button.disabled = false; + + if (!result.ok) { + setStatus(""); + var serverMsg = result.data && result.data.error ? result.data.error : ""; + var msg = "Upload failed (HTTP " + result.status + ")."; + if (serverMsg) { + msg += " " + serverMsg; + } + setError(msg); + return; + } + + if (result.data && typeof result.data.error === "string" && result.data.error.length > 0) { + setStatus(""); + setError(result.data.error); + return; + } + + var table = result.data && typeof result.data.table === "string" ? result.data.table : ""; + var rows = result.data && typeof result.data.rows === "number" ? result.data.rows : 0; + + setStatus("Upload complete."); + showResult(rows, table); + }).catch(function (err) { + button.disabled = false; + setStatus(""); + var msg = err && err.message ? err.message : String(err); + setError("Network error while uploading: " + msg); + }); + } + + document.addEventListener("DOMContentLoaded", function () { + var form = $("upload-form"); + if (form) { + form.addEventListener("submit", handleSubmit); + } + }); +})(); diff --git a/cmd/any2anexoj-web/static/index.html b/cmd/any2anexoj-web/static/index.html new file mode 100644 index 0000000..83e89de --- /dev/null +++ b/cmd/any2anexoj-web/static/index.html @@ -0,0 +1,33 @@ + + + + + + any2anexoj web + + + +
+

any2anexoj web

+

Upload a Trading212-style CSV and view the rendered tax report.

+ +
+ + + + +
+ +
+ + + +
+ + + + diff --git a/cmd/any2anexoj-web/static/style.css b/cmd/any2anexoj-web/static/style.css new file mode 100644 index 0000000..0acb42f --- /dev/null +++ b/cmd/any2anexoj-web/static/style.css @@ -0,0 +1,168 @@ +:root { + --bg: #fafafa; + --fg: #1f2328; + --muted: #57606a; + --accent: #0969da; + --accent-hover: #0860c7; + --border: #d0d7de; + --error-bg: #ffebe9; + --error-fg: #82071e; + --status-fg: #1f2328; + --code-bg: #f6f8fa; + --code-fg: #1f2328; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background-color: var(--bg); + color: var(--fg); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + font-size: 16px; + line-height: 1.5; +} + +.container { + max-width: 960px; + margin: 0 auto; + padding: 2rem 1.5rem 4rem; +} + +h1 { + margin: 0 0 0.5rem; + font-size: 2rem; + font-weight: 600; +} + +h2 { + margin: 0 0 0.75rem; + font-size: 1.25rem; + font-weight: 600; +} + +.lede { + margin: 0 0 2rem; + color: var(--muted); +} + +.upload-form { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + padding: 1rem; + background-color: #ffffff; + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 1rem; +} + +.file-label { + font-weight: 500; +} + +input[type="file"] { + font: inherit; + flex: 1 1 240px; + min-width: 0; +} + +button { + font: inherit; + font-weight: 500; + padding: 0.5rem 1rem; + background-color: var(--accent); + color: #ffffff; + border: 1px solid var(--accent); + border-radius: 6px; + cursor: pointer; +} + +button:hover:not(:disabled) { + background-color: var(--accent-hover); + border-color: var(--accent-hover); +} + +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.status { + margin: 0.5rem 0 0; + color: var(--status-fg); + min-height: 1.25rem; + font-size: 0.95rem; +} + +.error { + margin: 0.5rem 0 1rem; + padding: 0.75rem 1rem; + background-color: var(--error-bg); + color: var(--error-fg); + border: 1px solid #ffcecb; + border-radius: 6px; + font-size: 0.95rem; + display: none; + white-space: pre-wrap; +} + +.error:not(:empty) { + display: block; +} + +.result { + margin-top: 1.5rem; +} + +.result[hidden] { + display: none; +} + +.rows-info { + margin: 0 0 0.75rem; + color: var(--muted); + font-size: 0.95rem; +} + +.table-output { + margin: 0; + padding: 1rem; + background-color: var(--code-bg); + color: var(--code-fg); + border: 1px solid var(--border); + border-radius: 6px; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; + font-size: 0.85rem; + line-height: 1.4; + overflow-x: auto; + overflow-y: visible; + white-space: pre; + max-width: 100%; +} + +@media (max-width: 600px) { + .container { + padding: 1.25rem 1rem 3rem; + } + + h1 { + font-size: 1.5rem; + } + + .upload-form { + flex-direction: column; + align-items: stretch; + } + + button { + width: 100%; + } +} diff --git a/cmd/any2anexoj-cli/localizer.go b/internal/prettyprinter/localizer.go similarity index 98% rename from cmd/any2anexoj-cli/localizer.go rename to internal/prettyprinter/localizer.go index a8cf756..74318ae 100644 --- a/cmd/any2anexoj-cli/localizer.go +++ b/internal/prettyprinter/localizer.go @@ -1,4 +1,4 @@ -package main +package prettyprinter import ( "embed" diff --git a/cmd/any2anexoj-cli/localizer_test.go b/internal/prettyprinter/localizer_test.go similarity index 73% rename from cmd/any2anexoj-cli/localizer_test.go rename to internal/prettyprinter/localizer_test.go index 2bad62f..1f16297 100644 --- a/cmd/any2anexoj-cli/localizer_test.go +++ b/internal/prettyprinter/localizer_test.go @@ -1,6 +1,10 @@ -package main +package prettyprinter_test -import "testing" +import ( + "testing" + + "github.com/nmoniz/any2anexoj/internal/prettyprinter" +) func TestNewLocalizer(t *testing.T) { tests := []struct { @@ -15,7 +19,7 @@ func TestNewLocalizer(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := NewLocalizer(tt.lang) + _, err := prettyprinter.NewLocalizer(tt.lang) if err != nil { t.Fatalf("want success call but failed: %v", err) } diff --git a/cmd/any2anexoj-cli/pretty_printer.go b/internal/prettyprinter/printer.go similarity index 93% rename from cmd/any2anexoj-cli/pretty_printer.go rename to internal/prettyprinter/printer.go index f102573..7012cca 100644 --- a/cmd/any2anexoj-cli/pretty_printer.go +++ b/internal/prettyprinter/printer.go @@ -1,4 +1,4 @@ -package main +package prettyprinter import ( "fmt" @@ -10,19 +10,21 @@ import ( "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 + translator translator } -type Translator interface { - Translate(key string, count int, values map[string]any) string -} - -func NewPrettyPrinter(w io.Writer, tr Translator) *PrettyPrinter { +func NewPrettyPrinter(w io.Writer, tr translator) *PrettyPrinter { tw := table.NewWriter() tw.SetOutputMirror(w) tw.SetAutoIndex(true) diff --git a/cmd/any2anexoj-cli/pretty_printer_test.go b/internal/prettyprinter/printer_test.go similarity index 96% rename from cmd/any2anexoj-cli/pretty_printer_test.go rename to internal/prettyprinter/printer_test.go index 72db220..50125e3 100644 --- a/cmd/any2anexoj-cli/pretty_printer_test.go +++ b/internal/prettyprinter/printer_test.go @@ -1,4 +1,4 @@ -package main +package prettyprinter_test import ( "bytes" @@ -6,6 +6,7 @@ import ( "time" "github.com/nmoniz/any2anexoj/internal" + "github.com/nmoniz/any2anexoj/internal/prettyprinter" "github.com/shopspring/decimal" ) @@ -48,14 +49,14 @@ func TestPrettyPrinter_Render(t *testing.T) { } // Create English localizer - localizer, err := NewLocalizer("en") + 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 := NewPrettyPrinter(&buf, localizer) + pp := prettyprinter.NewPrettyPrinter(&buf, localizer) // Render the table pp.Render(aw) diff --git a/cmd/any2anexoj-cli/translations/en.json b/internal/prettyprinter/translations/en.json similarity index 100% rename from cmd/any2anexoj-cli/translations/en.json rename to internal/prettyprinter/translations/en.json diff --git a/cmd/any2anexoj-cli/translations/pt.json b/internal/prettyprinter/translations/pt.json similarity index 100% rename from cmd/any2anexoj-cli/translations/pt.json rename to internal/prettyprinter/translations/pt.json diff --git a/internal/web/auth.go b/internal/web/auth.go new file mode 100644 index 0000000..0f27319 --- /dev/null +++ b/internal/web/auth.go @@ -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) + }) +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go new file mode 100644 index 0000000..e1d320a --- /dev/null +++ b/internal/web/handlers.go @@ -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 +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..cd7a39e --- /dev/null +++ b/internal/web/server.go @@ -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) + }) +} diff --git a/internal/web/server_test.go b/internal/web/server_test.go new file mode 100644 index 0000000..8c211fb --- /dev/null +++ b/internal/web/server_test.go @@ -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(), " +test fixture