Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 149 additions & 34 deletions spec/System/TestTradeQueryCurrency_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ describe("TradeQuery Currency Conversion", function()
-- Pass: Calculates price in divs
-- Fail: Wrong value or nil, indicating broken rounding/baseline logic
it("handles chaos currency", function()
mock_tradeQuery.pbCurrencyConversion = { league = { chaos = 0.1 } }
mock_tradeQuery.pbCurrencyConversion = { realm = { league = { chaos = 0.1 } } }
mock_tradeQuery.pbRealm = "realm"
mock_tradeQuery.pbLeague = "league"
local result = mock_tradeQuery:ConvertCurrencyToDivs("chaos", 5)
assert.are.equal(result, 0.5)
Expand Down Expand Up @@ -48,40 +49,154 @@ describe("TradeQuery Currency Conversion", function()
assert.are.equals(1, mock_tradeQuery.itemIndexTbl[1])
end)

describe("PriceBuilderProcessPoENinjaResponse", function()
-- Pass: Processes without error, restoring map while adding a notice
-- Fail: Corrupts map or crashes, indicating fragile API response handling, breaking future conversions
it("handles empty response", function()
local orig_conv = mock_tradeQuery.currencyConversionTradeMap
mock_tradeQuery.currencyConversionTradeMap = { div = "id" }
mock_tradeQuery.pbLeague = "league"
mock_tradeQuery.pbCurrencyConversion = { league = {} }
mock_tradeQuery.controls.pbNotice = { label = "" }
local resp = { lines = { }}
mock_tradeQuery:PriceBuilderProcessPoENinjaResponse(resp.lines)
-- No crash expected
assert.is_true(true)
assert.is_true(mock_tradeQuery.controls.pbNotice.label == "No currencies received from PoE Ninja")
mock_tradeQuery.currencyConversionTradeMap = orig_conv

describe("PullCXData", function()
local dkjson = require("dkjson")

-- base type ids the currency exchange uses
local DIVINE = "Metadata/Items/Currency/CurrencyModValues"
local CHAOS = "Metadata/Items/Currency/CurrencyRerollRare"
local EXALT = "Metadata/Items/Currency/CurrencyAddModToRare"
local ALCH = "Metadata/Items/Currency/CurrencyUpgradeToRare"

-- static trade data: maps display name -> short trade id
local static = {
result = { {
entries = {
{ id = "sep", text = "" }, -- separator, must be ignored
{ id = "divine", text = "Divine Orb" },
{ id = "chaos", text = "Chaos Orb" },
{ id = "exalt", text = "Exalted Orb" },
{ id = "alch", text = "Orb of Alchemy" },
}
} }
}

local origDownloadPage, origApi

before_each(function()
mock_tradeQuery.pbRealm = "pc"
mock_tradeQuery.controls.pbNotice = {}
origDownloadPage = launch.DownloadPage
origApi = main.api
-- static trade data is fetched first via launch:DownloadPage
launch.DownloadPage = function(_, _url, callback)
callback({ body = dkjson.encode(static) })
end
end)

-- Pass: Processes without error, restoring map while adding a notice
-- Fail: Corrupts map or crashes, indicating fragile API response handling, breaking future conversions
it("handles empty response", function()
local orig_conv = mock_tradeQuery.currencyConversionTradeMap
mock_tradeQuery.currencyConversionTradeMap = { div = "id" }
mock_tradeQuery.pbLeague = "league"
mock_tradeQuery.pbCurrencyConversion = { league = {} }
mock_tradeQuery.controls.pbNotice = { label = "" }
local resp = { lines = { { malformedLine = "lol"} }}
mock_tradeQuery:PriceBuilderProcessPoENinjaResponse(resp.lines)
-- No crash expected
assert.is_true(true)
assert.is_true(mock_tradeQuery.controls.pbNotice.label == "Currencies not updated: malformed PoE Ninja response")
mock_tradeQuery.currencyConversionTradeMap = orig_conv
after_each(function()
launch.DownloadPage = origDownloadPage
main.api = origApi
end)
end)

-- helper: make main.api:FetchCurrencyExchange feed the given markets back
local function mockCX(markets)
main.api = {
FetchCurrencyExchange = function(_, _realm, callback)
callback({ body = dkjson.encode({ markets = markets }) })
end
}
end

it("converts a chained market to divine values", function()
mockCX({
-- exalt -> divine directly (1 exalt = 0.1 div)
{
league = "Standard",
market_pair = { EXALT, DIVINE },
lowest_ratio = { [EXALT] = 10, [DIVINE] = 1 },
highest_stock = { [EXALT] = 100, [DIVINE] = 100 },
},
-- chaos -> divine directly (1 chaos = 0.005 div)
{
league = "Standard",
market_pair = { CHAOS, DIVINE },
lowest_ratio = { [CHAOS] = 200, [DIVINE] = 1 },
highest_stock = { [CHAOS] = 500, [DIVINE] = 500 },
},
-- alch -> chaos (1 alch = 0.2 chaos), needs a second hop to divine
{
league = "Standard",
market_pair = { ALCH, CHAOS },
lowest_ratio = { [ALCH] = 5, [CHAOS] = 1 },
highest_stock = { [ALCH] = 1000, [CHAOS] = 1000 },
},
})

mock_tradeQuery:PullCXData()

local rates = mock_tradeQuery.pbCurrencyConversion.pc.Standard
assert.is_not_nil(rates)
assert.are.equal(1, rates.divine)
assert.are.equal(0.1, rates.exalt)
assert.are.equal(0.005, rates.chaos)
-- 0.2 chaos * 0.005 div/chaos = 0.001 div
assert.are.equal(0.001, rates.alch)
end)

it("keeps the highest-stock listing for a currency", function()
mockCX({
{
league = "Standard",
market_pair = { EXALT, DIVINE },
lowest_ratio = { [EXALT] = 10, [DIVINE] = 1 }, -- 0.1 div
highest_stock = { [EXALT] = 50, [DIVINE] = 5 },
},
{
league = "Standard",
market_pair = { EXALT, DIVINE },
lowest_ratio = { [EXALT] = 5, [DIVINE] = 1 }, -- 0.2 div
highest_stock = { [EXALT] = 200, [DIVINE] = 40 }, -- more stock, wins
},
})

mock_tradeQuery:PullCXData()

assert.are.equal(0.2, mock_tradeQuery.pbCurrencyConversion.pc.Standard.exalt)
end)

it("skips listings with a zero ratio", function()
mockCX({
{
league = "Standard",
market_pair = { EXALT, DIVINE },
lowest_ratio = { [EXALT] = 0, [DIVINE] = 1 },
highest_stock = { [EXALT] = 100, [DIVINE] = 100 },
},
})

mock_tradeQuery:PullCXData()

-- league had no usable listings, so it should not appear
assert.is_nil(mock_tradeQuery.pbCurrencyConversion.pc.Standard)
end)

it("shows a notice on an API error response", function()
main.api = {
FetchCurrencyExchange = function(_, _realm, callback)
callback({ body = dkjson.encode({ error = { message = "kaput" } }) })
end
}

mock_tradeQuery:PullCXData()

assert.are.equal("CX error: kaput", mock_tradeQuery.controls.pbNotice.label)
assert.is_nil(mock_tradeQuery.pbCurrencyConversion.pc)
end)

it("does not refetch within the rate-limit window", function()
mock_tradeQuery.pbCurrencyConversion.pc = { timestamp = os.time() }
local fetched = false
main.api = {
FetchCurrencyExchange = function() fetched = true end
}

mock_tradeQuery:PullCXData()

assert.is_false(fetched)
end)
end)
describe("GetTotalPriceString", function()
-- Pass: Sums and formats correctly (e.g., "5 chaos, 10 div", should be most valuable currency first)
-- Fail: Wrong string (e.g., unsorted/missing sums), indicating aggregation bug, misleading users on totals
Expand All @@ -92,14 +207,14 @@ describe("TradeQuery Currency Conversion", function()
assert.are.equal(result, "1 exalted, 10 div, 5 chaos")

-- check if they're sorted according to currency value
mock_tradeQuery.pbRealm = "realm"
mock_tradeQuery.pbLeague = "league"
mock_tradeQuery.pbCurrencyConversion = { league = { chaos = 0.1, exalted = 0.05, div = 1, mirror = 700} }
mock_tradeQuery.pbCurrencyConversion = { realm = { league = { chaos = 0.1, exalted = 0.05, div = 1, mirror = 700 } } }
local result = mock_tradeQuery:GetTotalPriceString()
assert.are.equal(result, "10 div, 5 chaos, 1 exalted")

-- check that missing currency values don't crash
mock_tradeQuery.pbLeague = "league"
mock_tradeQuery.pbCurrencyConversion = { league = { chaos = 0.1, exalted = 0.05, mirror = 700 } }
mock_tradeQuery.pbCurrencyConversion = { realm = { league = { chaos = 0.1, exalted = 0.05, mirror = 700 } } }
local result = mock_tradeQuery:GetTotalPriceString()
assert.True(true)
end)
Expand Down
19 changes: 17 additions & 2 deletions src/Classes/PoEAPI.lua
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ local PoEAPIClass = newClass("PoEAPI", function(self, authToken, refreshToken, t
self.refreshToken = refreshToken
self.tokenExpiry = tokenExpiry or 0
self.baseUrl = "https://api.pathofexile.com"
self.CDNBaseUrl = "https://web.poecdn.com/api"
self.rateLimiter = new("TradeQueryRateLimiter")
self.tokenHasBeenValidated = false

Expand Down Expand Up @@ -151,7 +152,8 @@ end

--- @param endpoint string
--- @param callback fun(response: table?, errorMsg: string)
function PoEAPIClass:DownloadWithRefresh(endpoint, callback)
function PoEAPIClass:DownloadWithRefresh(endpoint, callback, useCDN)
local baseUrl = useCDN and self.CDNBaseUrl or self.baseUrl
self:ValidateAuth(function(valid, validationErrMsg)
if not valid then
-- Clean info about token and refresh token
Expand All @@ -160,7 +162,7 @@ function PoEAPIClass:DownloadWithRefresh(endpoint, callback)
return
end

launch:DownloadPage(self.baseUrl .. endpoint, function(response, errMsg)
launch:DownloadPage(baseUrl .. endpoint, function(response, errMsg)
if errMsg and errMsg:match("401") and self.retries < 1 then
-- try once again with refresh token
self.retries = 1
Expand Down Expand Up @@ -232,3 +234,16 @@ function PoEAPIClass:DownloadCharacter(realm, name, callback)
self:DownloadWithRateLimit("character-request-limit",
"/character" .. (realm == "pc" and "" or "/" .. realm) .. "/" .. name, callback)
end

---@param realm string
---@param callback DownloadCallback
function PoEAPIClass:FetchCurrencyExchange(realm, callback)
local url = "/currency-exchange"
if realm ~= "pc" then
url = url .. "/" .. realm
end
local hourSeconds = 60 * 60
local unixTimeLastHour = (math.floor(os.time() / hourSeconds) - 1) * hourSeconds
url = url .. "/" .. unixTimeLastHour
self:DownloadWithRefresh(url, callback, true)
end
Loading
Loading