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

+37
View File
@@ -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 <http://localhost:8080> 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": "<rendered>", "rows": <int>}`. |
| 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.
+3 -2
View File
@@ -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)
+85
View File
@@ -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
}
}
+108
View File
@@ -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);
}
});
})();
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>any2anexoj web</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main class="container">
<h1>any2anexoj web</h1>
<p class="lede">Upload a Trading212-style CSV and view the rendered tax report.</p>
<form id="upload-form" class="upload-form" novalidate>
<label class="file-label" for="csv">Choose CSV file</label>
<input type="file" id="csv" name="csv" accept=".csv,text/csv">
<button type="submit" id="upload-button">Upload</button>
</form>
<div id="status" class="status" role="status" aria-live="polite"></div>
<div id="error" class="error" role="alert" aria-live="assertive"></div>
<section id="result" class="result" hidden>
<h2 id="result-heading">Result</h2>
<p id="rows-info" class="rows-info"></p>
<pre id="table-output" class="table-output"></pre>
</section>
</main>
<script src="/static/app.js" defer></script>
</body>
</html>
+168
View File
@@ -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%;
}
}
@@ -1,4 +1,4 @@
package main
package prettyprinter
import (
"embed"
@@ -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)
}
@@ -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)
@@ -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)
+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>