Files
miti99bot/internal/modules/stock/prices_test.go
T

255 lines
8.4 KiB
Go

package stock
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func newTestPriceClient(t *testing.T, handler http.HandlerFunc) (*PriceClient, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return &PriceClient{
HTTP: &http.Client{Timeout: 2 * time.Second},
URL: srv.URL,
}, srv
}
func TestPriceClient_HappyPath(t *testing.T) {
c, _ := newTestPriceClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
if r.URL.Path != "/stock/TCB" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if got := r.Header.Get("Origin"); got != "https://iboard.ssi.com.vn" {
t.Errorf("origin = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"stockSymbol":"TCB","matchedPrice":24500}}`))
})
got, err := c.FetchPrice(context.Background(), "TCB")
if err != nil {
t.Fatalf("FetchPrice: %v", err)
}
if got != 24500 {
t.Errorf("price: got %v, want 24500", got)
}
}
func TestPriceClient_BatchHappyPath(t *testing.T) {
c, _ := newTestPriceClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if r.URL.Path != "/stock/multiple" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if got := r.Header.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
t.Errorf("content-type = %q", got)
}
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm: %v", err)
}
stocks := r.Form["stocks"]
if strings.Join(stocks, ",") != "TCB,FPT" {
t.Errorf("stocks = %v, want [TCB FPT]", stocks)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"stockSymbol":"TCB","matchedPrice":24500},{"stockSymbol":"FPT","matchedPrice":120000}]}`))
})
got, err := c.FetchPrices(context.Background(), []string{"TCB", "FPT"})
if err != nil {
t.Fatalf("FetchPrices: %v", err)
}
if got["TCB"] != 24500 || got["FPT"] != 120000 {
t.Errorf("prices = %+v", got)
}
}
func TestPriceClient_FallsBackToKBSWhenSSIDirectBlocked(t *testing.T) {
ssiSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("<title>Security Check - SSI</title>"))
}))
t.Cleanup(ssiSrv.Close)
kbsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if r.URL.Path != "/" {
t.Errorf("path = %q, want /", r.URL.Path)
}
var body kbsRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("Decode KBS request: %v", err)
}
if body.Code != "TCB" {
t.Errorf("KBS code = %q, want TCB", body.Code)
}
if got := r.Header.Get("User-Agent"); !strings.Contains(got, "Chrome/120") {
t.Errorf("KBS user-agent = %q, want browser-like", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"SB":"TCB","CP":33400}]`))
}))
t.Cleanup(kbsSrv.Close)
c := &PriceClient{HTTP: ssiSrv.Client(), URL: ssiSrv.URL, KBSURL: kbsSrv.URL}
got, err := c.FetchPrice(context.Background(), "TCB")
if err != nil {
t.Fatalf("FetchPrice: %v", err)
}
if got != 33400 {
t.Errorf("price: got %v, want 33400", got)
}
}
func TestPriceClient_BatchFallsBackToKBSWhenSSIDirectBlocked(t *testing.T) {
ssiSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("<title>Security Check - SSI</title>"))
}))
t.Cleanup(ssiSrv.Close)
var gotCodes []string
kbsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body kbsRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("Decode KBS request: %v", err)
}
gotCodes = append(gotCodes, body.Code)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"SB":"TCB","CP":33400},{"SB":"FPT","CP":71000}]`))
}))
t.Cleanup(kbsSrv.Close)
c := &PriceClient{HTTP: ssiSrv.Client(), URL: ssiSrv.URL, KBSURL: kbsSrv.URL}
got, err := c.FetchPrices(context.Background(), []string{"TCB", "FPT"})
if err != nil {
t.Fatalf("FetchPrices: %v", err)
}
if got["TCB"] != 33400 || got["FPT"] != 71000 {
t.Errorf("prices = %+v", got)
}
if strings.Join(gotCodes, ",") != "TCB,FPT" {
t.Errorf("KBS codes = %v, want one batch TCB,FPT", gotCodes)
}
}
func TestPriceClient_BatchFallsBackToVCIWhenKBSBlocked(t *testing.T) {
ssiSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("<title>Security Check - SSI</title>"))
}))
t.Cleanup(ssiSrv.Close)
kbsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
t.Cleanup(kbsSrv.Close)
var gotSymbols []string
vciSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body vciRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("Decode VCI request: %v", err)
}
gotSymbols = append(gotSymbols, body.Symbols...)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"listingInfo":{"symbol":"TCB"},"matchPrice":{"matchPrice":33400}},
{"listingInfo":{"symbol":"FPT"},"matchPrice":{"matchPrice":71000}}
]`))
}))
t.Cleanup(vciSrv.Close)
c := &PriceClient{HTTP: ssiSrv.Client(), URL: ssiSrv.URL, KBSURL: kbsSrv.URL, VCIURL: vciSrv.URL}
got, err := c.FetchPrices(context.Background(), []string{"TCB", "FPT"})
if err != nil {
t.Fatalf("FetchPrices: %v", err)
}
if got["TCB"] != 33400 || got["FPT"] != 71000 {
t.Errorf("prices = %+v", got)
}
if strings.Join(gotSymbols, ",") != "TCB,FPT" {
t.Errorf("VCI symbols = %v, want TCB,FPT", gotSymbols)
}
}
func TestPriceClient_BatchOmitsInvalidQuotes(t *testing.T) {
c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":[{"stockSymbol":"TCB","matchedPrice":24500},{"stockSymbol":"BAD","matchedPrice":0},{"stockSymbol":"NEG","matchedPrice":-1}]}`))
})
got, err := c.FetchPrices(context.Background(), []string{"TCB", "BAD", "NEG"})
if err != nil {
t.Fatalf("FetchPrices: %v", err)
}
if len(got) != 1 || got["TCB"] != 24500 {
t.Errorf("prices = %+v, want only TCB", got)
}
}
func TestPriceClient_NoData_ReturnsErrNoPrice(t *testing.T) {
c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":null}`))
})
_, err := c.FetchPrice(context.Background(), "NOPE")
if !errors.Is(err, ErrNoPrice) {
t.Errorf("got %v, want ErrNoPrice", err)
}
}
func TestPriceClient_BatchNoUsableData_ReturnsErrNoPrice(t *testing.T) {
c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":[{"stockSymbol":"NOPE","matchedPrice":0}]}`))
})
_, err := c.FetchPrices(context.Background(), []string{"NOPE"})
if !errors.Is(err, ErrNoPrice) {
t.Errorf("got %v, want ErrNoPrice", err)
}
if !strings.Contains(err.Error(), "SSI batch returned no usable quotes for NOPE (data_len=1)") {
t.Errorf("error = %q, want batch diagnostic", err.Error())
}
}
func TestPriceClient_4xx_ReturnsErrNoPrice(t *testing.T) {
c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("blocked by upstream"))
})
_, err := c.FetchPrice(context.Background(), "BADTICKER")
if !errors.Is(err, ErrNoPrice) {
t.Errorf("got %v, want ErrNoPrice", err)
}
if !strings.Contains(err.Error(), "SSI status 404 body") || !strings.Contains(err.Error(), "blocked by upstream") {
t.Errorf("error = %q, want status/body diagnostic", err.Error())
}
}
func TestPriceClient_NegativeMatchedPrice_ReturnsErrNoPrice(t *testing.T) {
c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":{"stockSymbol":"WEIRD","matchedPrice":-1}}`))
})
_, err := c.FetchPrice(context.Background(), "WEIRD")
if !errors.Is(err, ErrNoPrice) {
t.Errorf("got %v, want ErrNoPrice", err)
}
}
func TestPriceClient_EmptyTicker(t *testing.T) {
c := &PriceClient{}
_, err := c.FetchPrice(context.Background(), "")
if err == nil {
t.Error("empty ticker: expected error, got nil")
}
}