Item price support
This commit is contained in:
@@ -96,31 +96,40 @@ type VolumeEntry struct {
|
||||
}
|
||||
|
||||
// TimeseriesResponse wraps the 5m/1h API responses.
|
||||
// The API returns data as a map keyed by item ID string.
|
||||
type TimeseriesResponse struct {
|
||||
Data []VolumeEntry `json:"data"`
|
||||
Data map[string]VolumeEntry `json:"data"`
|
||||
}
|
||||
|
||||
// Get5Min fetches 5-minute average data for an item.
|
||||
func (c *Client) Get5Min(itemID int) (*TimeseriesResponse, error) {
|
||||
func (c *Client) Get5Min(itemID int) (*VolumeEntry, error) {
|
||||
url := fmt.Sprintf("%s/5m?id=%d", c.baseURL, itemID)
|
||||
var resp TimeseriesResponse
|
||||
if err := c.getJSON(url, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
idStr := fmt.Sprintf("%d", itemID)
|
||||
if entry, ok := resp.Data[idStr]; ok {
|
||||
return &entry, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no 5m data for item %d", itemID)
|
||||
}
|
||||
|
||||
// Get1Hour fetches 1-hour average data for an item.
|
||||
func (c *Client) Get1Hour(itemID int) (*TimeseriesResponse, error) {
|
||||
func (c *Client) Get1Hour(itemID int) (*VolumeEntry, error) {
|
||||
url := fmt.Sprintf("%s/1h?id=%d", c.baseURL, itemID)
|
||||
var resp TimeseriesResponse
|
||||
if err := c.getJSON(url, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
idStr := fmt.Sprintf("%d", itemID)
|
||||
if entry, ok := resp.Data[idStr]; ok {
|
||||
return &entry, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no 1h data for item %d", itemID)
|
||||
}
|
||||
|
||||
func (c *Client) getJSON(url string, dest interface{}) error {
|
||||
func (c *Client) getJSON(url string, dest any) error {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
@@ -131,7 +140,7 @@ func (c *Client) getJSON(url string, dest interface{}) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("executing request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
268
scripts/rsw/internal/prices/integration_test.go
Normal file
268
scripts/rsw/internal/prices/integration_test.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package prices_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/runescape-wiki/rsw/internal/prices"
|
||||
)
|
||||
|
||||
const osrsPriceBaseURL = "https://prices.runescape.wiki/api/v1/osrs"
|
||||
|
||||
func osrsClient() *prices.Client {
|
||||
return prices.NewClient(osrsPriceBaseURL)
|
||||
}
|
||||
|
||||
// --- OSRS Mapping Tests ---
|
||||
|
||||
func TestOSRS_GetMapping_ReturnsItems(t *testing.T) {
|
||||
items, err := osrsClient().GetMapping()
|
||||
if err != nil {
|
||||
t.Fatalf("GetMapping failed: %v", err)
|
||||
}
|
||||
if len(items) < 100 {
|
||||
t.Fatalf("expected at least 100 items, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSRS_GetMapping_ContainsAbyssalWhip(t *testing.T) {
|
||||
items, err := osrsClient().GetMapping()
|
||||
if err != nil {
|
||||
t.Fatalf("GetMapping failed: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, item := range items {
|
||||
if strings.EqualFold(item.Name, "abyssal whip") {
|
||||
found = true
|
||||
if item.ID != 4151 {
|
||||
t.Errorf("expected Abyssal whip ID to be 4151, got %d", item.ID)
|
||||
}
|
||||
if !item.Members {
|
||||
t.Error("expected Abyssal whip to be members-only")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected to find 'Abyssal whip' in mapping")
|
||||
}
|
||||
}
|
||||
|
||||
// --- OSRS MappingCache Tests ---
|
||||
|
||||
func TestOSRS_MappingCache_LookupByName(t *testing.T) {
|
||||
mc := prices.NewMappingCache(osrsClient(), "osrs")
|
||||
if err := mc.Load(); err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
item := mc.LookupByName("Abyssal whip")
|
||||
if item == nil {
|
||||
t.Fatal("expected to find Abyssal whip")
|
||||
}
|
||||
if item.ID != 4151 {
|
||||
t.Errorf("expected ID 4151, got %d", item.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSRS_MappingCache_LookupByName_CaseInsensitive(t *testing.T) {
|
||||
mc := prices.NewMappingCache(osrsClient(), "osrs")
|
||||
if err := mc.Load(); err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
item := mc.LookupByName("ABYSSAL WHIP")
|
||||
if item == nil {
|
||||
t.Fatal("expected case-insensitive lookup to find Abyssal whip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSRS_MappingCache_SearchByName(t *testing.T) {
|
||||
mc := prices.NewMappingCache(osrsClient(), "osrs")
|
||||
if err := mc.Load(); err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
results := mc.SearchByName("dragon")
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected to find items containing 'dragon'")
|
||||
}
|
||||
|
||||
for _, item := range results {
|
||||
if !strings.Contains(strings.ToLower(item.Name), "dragon") {
|
||||
t.Errorf("result %q does not contain 'dragon'", item.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSRS_MappingCache_LookupByID(t *testing.T) {
|
||||
mc := prices.NewMappingCache(osrsClient(), "osrs")
|
||||
if err := mc.Load(); err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
item := mc.LookupByID(4151)
|
||||
if item == nil {
|
||||
t.Fatal("expected to find item 4151")
|
||||
}
|
||||
if !strings.EqualFold(item.Name, "abyssal whip") {
|
||||
t.Errorf("expected 'Abyssal whip', got %q", item.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- OSRS Latest Price Tests ---
|
||||
|
||||
func TestOSRS_GetLatestForItem_AbyssalWhip(t *testing.T) {
|
||||
latest, err := osrsClient().GetLatestForItem(4151)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestForItem failed: %v", err)
|
||||
}
|
||||
|
||||
if latest.High == nil && latest.Low == nil {
|
||||
t.Error("expected at least one of High or Low to be non-nil")
|
||||
}
|
||||
if latest.High != nil && *latest.High <= 0 {
|
||||
t.Errorf("expected positive high price, got %d", *latest.High)
|
||||
}
|
||||
if latest.Low != nil && *latest.Low <= 0 {
|
||||
t.Errorf("expected positive low price, got %d", *latest.Low)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSRS_GetLatestForItem_Nonexistent(t *testing.T) {
|
||||
_, err := osrsClient().GetLatestForItem(999999999)
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent item ID")
|
||||
}
|
||||
}
|
||||
|
||||
// --- OSRS Timeseries Tests ---
|
||||
|
||||
func TestOSRS_Get1Hour_AbyssalWhip(t *testing.T) {
|
||||
entry, err := osrsClient().Get1Hour(4151)
|
||||
if err != nil {
|
||||
t.Fatalf("Get1Hour failed: %v", err)
|
||||
}
|
||||
|
||||
if entry.AvgHighPrice == nil {
|
||||
t.Error("expected AvgHighPrice to be non-nil for Abyssal whip")
|
||||
} else if *entry.AvgHighPrice <= 0 {
|
||||
t.Errorf("expected positive AvgHighPrice, got %d", *entry.AvgHighPrice)
|
||||
}
|
||||
if entry.AvgLowPrice == nil {
|
||||
t.Error("expected AvgLowPrice to be non-nil for Abyssal whip")
|
||||
} else if *entry.AvgLowPrice <= 0 {
|
||||
t.Errorf("expected positive AvgLowPrice, got %d", *entry.AvgLowPrice)
|
||||
}
|
||||
if entry.HighVolume <= 0 {
|
||||
t.Errorf("expected positive buy volume, got %d", entry.HighVolume)
|
||||
}
|
||||
if entry.LowVolume <= 0 {
|
||||
t.Errorf("expected positive sell volume, got %d", entry.LowVolume)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSRS_Get5Min_AbyssalWhip(t *testing.T) {
|
||||
entry, err := osrsClient().Get5Min(4151)
|
||||
if err != nil {
|
||||
t.Fatalf("Get5Min failed: %v", err)
|
||||
}
|
||||
|
||||
if entry.AvgHighPrice == nil {
|
||||
t.Error("expected AvgHighPrice to be non-nil for Abyssal whip")
|
||||
} else if *entry.AvgHighPrice <= 0 {
|
||||
t.Errorf("expected positive AvgHighPrice, got %d", *entry.AvgHighPrice)
|
||||
}
|
||||
if entry.AvgLowPrice == nil {
|
||||
t.Error("expected AvgLowPrice to be non-nil for Abyssal whip")
|
||||
} else if *entry.AvgLowPrice <= 0 {
|
||||
t.Errorf("expected positive AvgLowPrice, got %d", *entry.AvgLowPrice)
|
||||
}
|
||||
}
|
||||
|
||||
// --- RS3 Client Tests ---
|
||||
|
||||
func TestRS3_GetDetail_AbyssalWhip(t *testing.T) {
|
||||
c := prices.NewRS3Client()
|
||||
detail, err := c.GetDetail(4151)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDetail failed: %v", err)
|
||||
}
|
||||
|
||||
if detail.Name != "Abyssal whip" {
|
||||
t.Errorf("expected name 'Abyssal whip', got %q", detail.Name)
|
||||
}
|
||||
if detail.CurrentPrice == "" {
|
||||
t.Error("expected non-empty current price")
|
||||
}
|
||||
if detail.ID != 4151 {
|
||||
t.Errorf("expected ID 4151, got %d", detail.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRS3_GetDetail_BluePartyhat(t *testing.T) {
|
||||
c := prices.NewRS3Client()
|
||||
detail, err := c.GetDetail(1042)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDetail failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(strings.ToLower(detail.Name), "partyhat") &&
|
||||
!strings.Contains(strings.ToLower(detail.Name), "party hat") {
|
||||
t.Errorf("expected name to contain 'partyhat', got %q", detail.Name)
|
||||
}
|
||||
if detail.CurrentPrice == "" {
|
||||
t.Error("expected non-empty current price")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRS3_GetDetail_HasTrends(t *testing.T) {
|
||||
c := prices.NewRS3Client()
|
||||
detail, err := c.GetDetail(4151)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDetail failed: %v", err)
|
||||
}
|
||||
|
||||
if detail.Day30Change == "" {
|
||||
t.Error("expected non-empty 30-day change")
|
||||
}
|
||||
if detail.Day90Change == "" {
|
||||
t.Error("expected non-empty 90-day change")
|
||||
}
|
||||
if detail.Day180Change == "" {
|
||||
t.Error("expected non-empty 180-day change")
|
||||
}
|
||||
|
||||
validTrends := map[string]bool{"positive": true, "negative": true, "neutral": true}
|
||||
if !validTrends[detail.Day30Trend] {
|
||||
t.Errorf("unexpected 30-day trend %q", detail.Day30Trend)
|
||||
}
|
||||
if !validTrends[detail.Day90Trend] {
|
||||
t.Errorf("unexpected 90-day trend %q", detail.Day90Trend)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRS3_GetDetail_Nonexistent(t *testing.T) {
|
||||
c := prices.NewRS3Client()
|
||||
_, err := c.GetDetail(999999999)
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent RS3 item ID")
|
||||
}
|
||||
}
|
||||
|
||||
// --- RS3 Exchange Module Tests (via wiki client) ---
|
||||
// These are in the wiki package test file, but we verify the RS3 price
|
||||
// client integrates correctly with real item IDs from exchange modules.
|
||||
|
||||
func TestRS3_GetDetail_Members_Field(t *testing.T) {
|
||||
c := prices.NewRS3Client()
|
||||
detail, err := c.GetDetail(4151)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDetail failed: %v", err)
|
||||
}
|
||||
|
||||
if detail.Members != "true" {
|
||||
t.Errorf("expected Abyssal whip members='true', got %q", detail.Members)
|
||||
}
|
||||
}
|
||||
@@ -18,16 +18,19 @@ type MappingCache struct {
|
||||
byID map[int]*MappingItem
|
||||
byName map[string]*MappingItem
|
||||
cacheDir string
|
||||
game string
|
||||
}
|
||||
|
||||
// NewMappingCache creates a mapping cache backed by the given client.
|
||||
func NewMappingCache(client *Client) *MappingCache {
|
||||
// The game parameter scopes the cache file (e.g. "osrs" or "rs3").
|
||||
func NewMappingCache(client *Client, game string) *MappingCache {
|
||||
home, _ := os.UserHomeDir()
|
||||
return &MappingCache{
|
||||
client: client,
|
||||
byID: make(map[int]*MappingItem),
|
||||
byName: make(map[string]*MappingItem),
|
||||
cacheDir: filepath.Join(home, ".rsw", "cache"),
|
||||
game: game,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +84,8 @@ func (mc *MappingCache) index() {
|
||||
}
|
||||
|
||||
func (mc *MappingCache) cachePath() string {
|
||||
return filepath.Join(mc.cacheDir, "mapping.json")
|
||||
filename := mc.game + "_mapping.json"
|
||||
return filepath.Join(mc.cacheDir, filename)
|
||||
}
|
||||
|
||||
func (mc *MappingCache) loadFromDisk() bool {
|
||||
|
||||
125
scripts/rsw/internal/prices/rs3client.go
Normal file
125
scripts/rsw/internal/prices/rs3client.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package prices
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const rs3BaseURL = "https://secure.runescape.com/m=itemdb_rs"
|
||||
|
||||
// RS3Client wraps HTTP requests to the Jagex RS3 Grand Exchange API.
|
||||
type RS3Client struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewRS3Client creates an RS3 price API client.
|
||||
func NewRS3Client() *RS3Client {
|
||||
return &RS3Client{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// RS3ItemDetail holds current price and trend data for an RS3 item.
|
||||
type RS3ItemDetail struct {
|
||||
ID int
|
||||
Name string
|
||||
Description string
|
||||
Members string
|
||||
CurrentPrice string // pre-formatted, e.g. "91.6b"
|
||||
CurrentTrend string
|
||||
TodayPrice int
|
||||
TodayTrend string
|
||||
Day30Change string
|
||||
Day30Trend string
|
||||
Day90Change string
|
||||
Day90Trend string
|
||||
Day180Change string
|
||||
Day180Trend string
|
||||
}
|
||||
|
||||
// rs3DetailResponse matches the JSON shape from the Jagex catalogue API.
|
||||
type rs3DetailResponse struct {
|
||||
Item struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
Members string `json:"members"`
|
||||
Current struct {
|
||||
Trend string `json:"trend"`
|
||||
Price any `json:"price"` // string or int
|
||||
} `json:"current"`
|
||||
Today struct {
|
||||
Trend string `json:"trend"`
|
||||
Price any `json:"price"` // int or string
|
||||
} `json:"today"`
|
||||
Day30 struct {
|
||||
Trend string `json:"trend"`
|
||||
Change string `json:"change"`
|
||||
} `json:"day30"`
|
||||
Day90 struct {
|
||||
Trend string `json:"trend"`
|
||||
Change string `json:"change"`
|
||||
} `json:"day90"`
|
||||
Day180 struct {
|
||||
Trend string `json:"trend"`
|
||||
Change string `json:"change"`
|
||||
} `json:"day180"`
|
||||
} `json:"item"`
|
||||
}
|
||||
|
||||
// GetDetail fetches current price and trend data for an RS3 item by ID.
|
||||
func (c *RS3Client) GetDetail(itemID int) (*RS3ItemDetail, error) {
|
||||
url := fmt.Sprintf("%s/api/catalogue/detail.json?item=%d", rs3BaseURL, itemID)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("executing request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("RS3 API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var raw rs3DetailResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
|
||||
detail := &RS3ItemDetail{
|
||||
ID: raw.Item.ID,
|
||||
Name: raw.Item.Name,
|
||||
Description: raw.Item.Description,
|
||||
Members: raw.Item.Members,
|
||||
CurrentPrice: fmt.Sprintf("%v", raw.Item.Current.Price),
|
||||
CurrentTrend: raw.Item.Current.Trend,
|
||||
TodayTrend: raw.Item.Today.Trend,
|
||||
Day30Change: raw.Item.Day30.Change,
|
||||
Day30Trend: raw.Item.Day30.Trend,
|
||||
Day90Change: raw.Item.Day90.Change,
|
||||
Day90Trend: raw.Item.Day90.Trend,
|
||||
Day180Change: raw.Item.Day180.Change,
|
||||
Day180Trend: raw.Item.Day180.Trend,
|
||||
}
|
||||
|
||||
// today.price can be int or string
|
||||
switch v := raw.Item.Today.Price.(type) {
|
||||
case float64:
|
||||
detail.TodayPrice = int(v)
|
||||
}
|
||||
|
||||
return detail, nil
|
||||
}
|
||||
Reference in New Issue
Block a user