package main import ( "bytes" "os" "path/filepath" "strings" "testing" "github.com/spf13/pflag" ) // resetFlags puts every pflag-backed variable back to its default so each // test that calls runWithIO sees a clean slate regardless of test ordering // or arguments the previous test set via pflag.Set. func resetFlags(t *testing.T) { t.Helper() if err := pflag.Set("platform", "trading212"); err != nil { t.Fatalf("reset platform flag: %v", err) } if err := pflag.Set("language", "en"); err != nil { t.Fatalf("reset language flag: %v", err) } if err := pflag.Set("debug", "false"); err != nil { t.Fatalf("reset debug flag: %v", err) } if err := pflag.Set("format", "table"); err != nil { t.Fatalf("reset format flag: %v", err) } if err := pflag.Set("open-figi-api-key", ""); err != nil { t.Fatalf("reset open-figi-api-key flag: %v", err) } if err := pflag.Set("selectors", ""); err != nil { t.Fatalf("reset selectors flag: %v", err) } if err := pflag.Set("state-file", ""); err != nil { t.Fatalf("reset state-file flag: %v", err) } } // trading212SampleCSV is a minimal Trading212 export with a header row and // one market buy + one matching market sell so BuildReport reaches EOF and // exercises the store.Save path. The line format mirrors the fixtures in // internal/trading212/record_test.go (20 columns). const trading212SampleCSV = `Action,Time,ISIN,Ticker,Name,Notes,Quantity,Price,Price currency,Exchange rate,Result,Result currency,Charges,Charges currency,Stamp duty,Stamp duty currency,Conversion fee,Conversion fee currency,French transaction tax,French transaction tax currency Market buy,2025-07-03 10:44:29,XX1234567890,ABXY,"Asparagus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",, Market sell,2025-08-04 11:45:30,XX1234567890,ABXY,"Asparagus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",0.1,"EUR" ` // runWithStdin runs runWithIO against the supplied stdin payload. func runWithStdin(t *testing.T, stdin string, stdout *bytes.Buffer) error { t.Helper() if err := runWithIO(t.Context(), strings.NewReader(stdin), stdout); err != nil { return err } return nil } // TestRunWithIO_StateFileCreated verifies that running the CLI with // --state-file produces a state file on disk after a successful EOF. func TestRunWithIO_StateFileCreated(t *testing.T) { resetFlags(t) t.Cleanup(func() { resetFlags(t) }) dir := t.TempDir() statePath := filepath.Join(dir, "state.json") if err := pflag.Set("state-file", statePath); err != nil { t.Fatalf("set state-file flag: %v", err) } if err := pflag.Set("format", "csv"); err != nil { t.Fatalf("set format flag: %v", err) } var stdout bytes.Buffer if err := runWithStdin(t, trading212SampleCSV, &stdout); err != nil { t.Fatalf("runWithIO returned an error: %v\nstdout: %s", err, stdout.String()) } info, err := os.Stat(statePath) if err != nil { t.Fatalf("expected state file at %s but stat returned error: %v", statePath, err) } if info.Size() == 0 { t.Fatalf("state file at %s is empty", statePath) } // State file must look like JSON with the expected version field. body, err := os.ReadFile(statePath) if err != nil { t.Fatalf("read state file: %v", err) } if !bytes.Contains(body, []byte(`"version"`)) { t.Errorf("state file missing version field, got: %s", body) } if !bytes.Contains(body, []byte(`"trading212"`)) { t.Errorf("state file missing trading212 platform, got: %s", body) } } // TestRunWithIO_NoStateFileByDefault verifies that omitting --state-file // behaves exactly like the pre-persistence CLI: nothing is written to disk // and the report still renders. func TestRunWithIO_NoStateFileByDefault(t *testing.T) { resetFlags(t) t.Cleanup(func() { resetFlags(t) }) // Use a temp working directory so any accidental file write would // show up clearly via t.TempDir's cleanup listing. dir := t.TempDir() cwd, err := os.Getwd() if err != nil { t.Fatalf("getwd: %v", err) } if err := os.Chdir(dir); err != nil { t.Fatalf("chdir: %v", err) } t.Cleanup(func() { _ = os.Chdir(cwd) }) if err := pflag.Set("format", "csv"); err != nil { t.Fatalf("set format flag: %v", err) } var stdout bytes.Buffer if err := runWithStdin(t, trading212SampleCSV, &stdout); err != nil { t.Fatalf("runWithIO returned an error: %v\nstdout: %s", err, stdout.String()) } entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("read tmp dir: %v", err) } for _, e := range entries { t.Errorf("unexpected file written without --state-file: %s", e.Name()) } if stdout.Len() == 0 { t.Errorf("expected non-empty csv output on stdout") } } // TestRunWithIO_UnsupportedPlatformForPersistence verifies that using // --state-file with an unknown platform surfaces a clear error rather than // silently falling back to EphemeralStore. func TestRunWithIO_UnsupportedPlatformForPersistence(t *testing.T) { resetFlags(t) t.Cleanup(func() { resetFlags(t) }) if err := pflag.Set("state-file", filepath.Join(t.TempDir(), "state.json")); err != nil { t.Fatalf("set state-file flag: %v", err) } // Currently only trading212 is wired through buildStore, but the // reader switch also only supports trading212, so the reader error // fires first. Either error is acceptable; we just need a clear // failure message. if err := pflag.Set("platform", "unknown-broker"); err != nil { t.Fatalf("set platform flag: %v", err) } var stdout bytes.Buffer err := runWithStdin(t, trading212SampleCSV, &stdout) if err == nil { t.Fatalf("expected an error for unsupported platform") } if !strings.Contains(err.Error(), "platform") { t.Errorf("expected error to mention platform, got: %v", err) } }