initial webapp
This commit is contained in:
1 parent
6c989c338c
commit
d32f2b3a66
18 files changed
+1145
-16
No files matched your search
@@ -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.
|
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.
|
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.
|
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.
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
||||||
|
"github.com/nmoniz/any2anexoj/internal/prettyprinter"
|
||||||
"github.com/nmoniz/any2anexoj/internal/trading212"
|
"github.com/nmoniz/any2anexoj/internal/trading212"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
@@ -100,11 +101,11 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
|
|||||||
case "csv":
|
case "csv":
|
||||||
return NewCSVWriter(stdout).Render(writer)
|
return NewCSVWriter(stdout).Render(writer)
|
||||||
case "table":
|
case "table":
|
||||||
loc, err := NewLocalizer(*lang)
|
loc, err := prettyprinter.NewLocalizer(*lang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create localizer: %w", err)
|
return fmt.Errorf("create localizer: %w", err)
|
||||||
}
|
}
|
||||||
NewPrettyPrinter(stdout, loc).Render(writer)
|
prettyprinter.NewPrettyPrinter(stdout, loc).Render(writer)
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported format %q: must be table or csv", *format)
|
return fmt.Errorf("unsupported format %q: must be table or csv", *format)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
@@ -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 (
|
import (
|
||||||
"embed"
|
"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) {
|
func TestNewLocalizer(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -15,7 +19,7 @@ func TestNewLocalizer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
_, err := NewLocalizer(tt.lang)
|
_, err := prettyprinter.NewLocalizer(tt.lang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("want success call but failed: %v", err)
|
t.Fatalf("want success call but failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package prettyprinter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,19 +10,21 @@ import (
|
|||||||
"github.com/nmoniz/any2anexoj/internal"
|
"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
|
// PrettyPrinter writes a simple, human readable, table row to the provided io.Writer for each
|
||||||
// ReportItem received.
|
// ReportItem received.
|
||||||
type PrettyPrinter struct {
|
type PrettyPrinter struct {
|
||||||
table table.Writer
|
table table.Writer
|
||||||
output io.Writer
|
output io.Writer
|
||||||
translator Translator
|
translator translator
|
||||||
}
|
}
|
||||||
|
|
||||||
type Translator interface {
|
func NewPrettyPrinter(w io.Writer, tr translator) *PrettyPrinter {
|
||||||
Translate(key string, count int, values map[string]any) string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPrettyPrinter(w io.Writer, tr Translator) *PrettyPrinter {
|
|
||||||
tw := table.NewWriter()
|
tw := table.NewWriter()
|
||||||
tw.SetOutputMirror(w)
|
tw.SetOutputMirror(w)
|
||||||
tw.SetAutoIndex(true)
|
tw.SetAutoIndex(true)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package prettyprinter_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
|
"github.com/nmoniz/any2anexoj/internal/prettyprinter"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,14 +49,14 @@ func TestPrettyPrinter_Render(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create English localizer
|
// Create English localizer
|
||||||
localizer, err := NewLocalizer("en")
|
localizer, err := prettyprinter.NewLocalizer("en")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create localizer: %v", err)
|
t.Fatalf("failed to create localizer: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create pretty printer with buffer
|
// Create pretty printer with buffer
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
pp := NewPrettyPrinter(&buf, localizer)
|
pp := prettyprinter.NewPrettyPrinter(&buf, localizer)
|
||||||
|
|
||||||
// Render the table
|
// Render the table
|
||||||
pp.Render(aw)
|
pp.Render(aw)
|
||||||
File renamed without changes.
File renamed without changes.
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html><body>test fixture</body></html>
|
||||||
Reference in new issue
Block a user