local describe = require("lua_test.test").describe
local test = require("lua_test.test").test
local expect = require("lua_test.test").expect
local mocks = require("lua_test.mocks")
local function reload_callback_modules(reset_res)
package.loaded["pasta.shiori.event.callback"] = nil
package.loaded["pasta.store"] = nil
if reset_res then
package.loaded["pasta.shiori.res"] = nil
end
local STORE = require("pasta.store")
local CALLBACK = require("pasta.shiori.event.callback")
CALLBACK.reset()
return CALLBACK, STORE
end
describe("CALLBACK - ID generation and staging", function()
local CALLBACK, STORE
local function setup()
mocks.reset()
mocks.install()
CALLBACK, STORE = reload_callback_modules(false)
end
test("next_event_id generates sequential IDs", function()
setup()
expect(CALLBACK.next_event_id()):toBe("OnPastaCallBack1")
expect(CALLBACK.next_event_id()):toBe("OnPastaCallBack2")
expect(CALLBACK.next_event_id()):toBe("OnPastaCallBack3")
end)
test("reset clears ID counter", function()
setup()
CALLBACK.next_event_id() CALLBACK.next_event_id() CALLBACK.reset()
expect(CALLBACK.next_event_id()):toBe("OnPastaCallBack1")
end)
test("stage_pending → consume_staged registers entry in pending", function()
setup()
CALLBACK.stage_pending("OnPastaCallBack1", 1000, "test timeout")
local co = coroutine.create(function() coroutine.yield() end)
coroutine.resume(co)
local act = { name = "test" }
local consumed = CALLBACK.consume_staged(co, act)
expect(consumed):toBe(true)
expect(CALLBACK.pending["OnPastaCallBack1"]).not_:toBe(nil)
expect(CALLBACK.pending["OnPastaCallBack1"].co):toBe(co)
expect(CALLBACK.pending["OnPastaCallBack1"].act):toBe(act)
expect(CALLBACK.pending["OnPastaCallBack1"].timeout_at):toBe(1000)
expect(CALLBACK.pending["OnPastaCallBack1"].on_timeout):toBe("test timeout")
end)
test("consume_staged sets STORE.co_callback", function()
setup()
CALLBACK.stage_pending("OnPastaCallBack1", 1000, nil)
local co = coroutine.create(function() coroutine.yield() end)
coroutine.resume(co)
CALLBACK.consume_staged(co, {})
expect(STORE.co_callback):toBe(co)
end)
test("consume_staged returns false when nothing staged", function()
setup()
local co = coroutine.create(function() coroutine.yield() end)
coroutine.resume(co)
local consumed = CALLBACK.consume_staged(co, {})
expect(consumed):toBe(false)
end)
test("multiple stage_pending without consume raises error", function()
setup()
CALLBACK.stage_pending("OnPastaCallBack1", 1000, nil)
local ok, err = pcall(function()
CALLBACK.stage_pending("OnPastaCallBack2", 2000, "second")
end)
expect(ok):toBe(false)
expect(tostring(err):find("multiple staging detected")).not_:toBe(nil)
end)
test("reset clears pending, staged, and STORE.co_callback", function()
setup()
CALLBACK.stage_pending("OnPastaCallBack1", 1000, nil)
local co = coroutine.create(function() coroutine.yield() end)
coroutine.resume(co)
CALLBACK.consume_staged(co, {})
expect(STORE.co_callback):toBe(co)
CALLBACK.reset()
expect(STORE.co_callback):toBe(nil)
expect(next(CALLBACK.pending)):toBe(nil)
end)
end)
describe("CALLBACK.try_route", function()
local CALLBACK
local function setup()
mocks.reset()
mocks.install()
CALLBACK = reload_callback_modules(true)
end
test("returns nil when req.id does not match any pending entry", function()
setup()
local req = { id = "OnUnknown", reference = {} }
local result = CALLBACK.try_route(req)
expect(result):toBe(nil)
end)
test("resumes coroutine with 1-based refs and returns 200 OK response", function()
setup()
local received_refs = nil
local co2 = coroutine.create(function()
local refs = coroutine.yield() received_refs = refs
coroutine.yield("hello world\\e") end)
coroutine.resume(co2)
local act = { name = "test_act" }
CALLBACK.pending["OnPastaCallBack1"] = {
co = co2,
act = act,
timeout_at = 999999,
on_timeout = "timeout reason",
}
local req = {
id = "OnPastaCallBack1",
reference = { [0] = "2.6.77" },
}
local result = CALLBACK.try_route(req)
expect(received_refs[1]):toBe("2.6.77")
expect(CALLBACK.pending["OnPastaCallBack1"]):toBe(nil)
expect(result:find("200 OK")).not_:toBe(nil)
expect(result:find("Value: hello world\\e")).not_:toBe(nil)
end)
test("converts multiple 0-indexed references to 1-based array", function()
setup()
local received_refs = nil
local co = coroutine.create(function()
local refs = coroutine.yield()
received_refs = refs
coroutine.yield("result")
end)
coroutine.resume(co)
CALLBACK.pending["OnPastaCallBack1"] = {
co = co,
act = {},
timeout_at = 999999,
on_timeout = nil,
}
local req = {
id = "OnPastaCallBack1",
reference = { [0] = "val0", [1] = "val1", [2] = "val2" },
}
CALLBACK.try_route(req)
expect(received_refs[1]):toBe("val0")
expect(received_refs[2]):toBe("val1")
expect(received_refs[3]):toBe("val2")
end)
test("handles callback chaining (R4) via consume_staged", function()
setup()
local co = coroutine.create(function()
coroutine.yield() CALLBACK.stage_pending("OnPastaCallBack2", 999999, "chained timeout")
coroutine.yield("intermediate response") end)
coroutine.resume(co)
local act = { name = "test_act" }
CALLBACK.pending["OnPastaCallBack1"] = {
co = co,
act = act,
timeout_at = 999999,
on_timeout = nil,
}
local req = {
id = "OnPastaCallBack1",
reference = { [0] = "first_value" },
}
local result = CALLBACK.try_route(req)
expect(CALLBACK.pending["OnPastaCallBack1"]):toBe(nil)
expect(CALLBACK.pending["OnPastaCallBack2"]).not_:toBe(nil)
expect(CALLBACK.pending["OnPastaCallBack2"].co):toBe(co)
expect(CALLBACK.pending["OnPastaCallBack2"].on_timeout):toBe("chained timeout")
expect(result:find("200 OK")).not_:toBe(nil)
end)
test("try_route: reference 欠落 req でもエラーにならず空 refs で resume する (3.49 G3)", function()
setup()
local received_refs = "not_set"
local co = coroutine.create(function()
received_refs = coroutine.yield()
coroutine.yield("recovered\\e")
end)
coroutine.resume(co)
CALLBACK.pending["OnPastaCallBack1"] = {
co = co,
act = {},
timeout_at = 999999,
on_timeout = nil,
}
local result = CALLBACK.try_route({ id = "OnPastaCallBack1" })
expect(type(received_refs)):toBe("table")
expect(next(received_refs)):toBe(nil)
expect(result:find("200 OK")).not_:toBe(nil)
expect(CALLBACK.pending["OnPastaCallBack1"]):toBe(nil)
end)
test("propagates error when coroutine errors during resume", function()
setup()
local co = coroutine.create(function()
coroutine.yield()
error("coroutine exploded")
end)
coroutine.resume(co)
CALLBACK.pending["OnPastaCallBack1"] = {
co = co,
act = {},
timeout_at = 999999,
on_timeout = nil,
}
local req = {
id = "OnPastaCallBack1",
reference = { [0] = "val" },
}
local ok, err = pcall(function()
CALLBACK.try_route(req)
end)
expect(ok):toBe(false)
expect(tostring(err):find("coroutine exploded")).not_:toBe(nil)
end)
end)
describe("CALLBACK.sweep", function()
local CALLBACK
local warn_calls
local function setup()
warn_calls = {}
mocks.reset()
mocks.install({
log = {
trace = function() end,
debug = function() end,
info = function() end,
warn = function(msg) warn_calls[#warn_calls + 1] = msg end,
error = function() end,
},
})
CALLBACK = reload_callback_modules(true)
end
test("does nothing when no entries exist", function()
setup()
local result = CALLBACK.sweep(1000)
expect(result):toBe(nil)
end)
test("does nothing when entries have not timed out", function()
setup()
local co = coroutine.create(function()
coroutine.yield()
end)
coroutine.resume(co)
CALLBACK.pending["OnPastaCallBack1"] = {
co = co,
act = {},
timeout_at = 2000,
on_timeout = "timeout",
}
local result = CALLBACK.sweep(1000)
expect(result):toBe(nil)
expect(CALLBACK.pending["OnPastaCallBack1"]).not_:toBe(nil)
end)
test("resumes with nil,on_timeout and returns 500 when on_timeout is string", function()
setup()
local received_args = {}
local co = coroutine.create(function()
local val, reason = coroutine.yield()
received_args.val = val
received_args.reason = reason
end)
coroutine.resume(co)
CALLBACK.pending["OnPastaCallBack1"] = {
co = co,
act = {},
timeout_at = 100,
on_timeout = "callback timeout: get_property",
}
local result = CALLBACK.sweep(200)
expect(received_args.val):toBe(nil)
expect(received_args.reason):toBe("callback timeout: get_property")
expect(CALLBACK.pending["OnPastaCallBack1"]):toBe(nil)
expect(result:find("500 Internal Server Error")).not_:toBe(nil)
expect(result:find("X%-Error%-Reason: callback timeout: get_property")).not_:toBe(nil)
expect(#warn_calls):toBe(1)
expect(warn_calls[1]:find("OnPastaCallBack1")).not_:toBe(nil)
end)
test("resumes with nil and returns nil when on_timeout is nil", function()
setup()
local resumed = false
local received_val = "not_set"
local co = coroutine.create(function()
local val = coroutine.yield()
received_val = val
resumed = true
end)
coroutine.resume(co)
CALLBACK.pending["OnPastaCallBack1"] = {
co = co,
act = {},
timeout_at = 100,
on_timeout = nil,
}
local result = CALLBACK.sweep(200)
expect(resumed):toBe(true)
expect(received_val):toBe(nil)
expect(CALLBACK.pending["OnPastaCallBack1"]):toBe(nil)
expect(result):toBe(nil)
expect(#warn_calls):toBe(0)
end)
test("returns only first string on_timeout as 500 among multiple timeouts", function()
setup()
local co1 = coroutine.create(function() coroutine.yield() end)
coroutine.resume(co1)
local co2 = coroutine.create(function() coroutine.yield() end)
coroutine.resume(co2)
CALLBACK.pending["OnPastaCallBack1"] = {
co = co1,
act = {},
timeout_at = 50,
on_timeout = "timeout reason 1",
}
CALLBACK.pending["OnPastaCallBack2"] = {
co = co2,
act = {},
timeout_at = 60,
on_timeout = "timeout reason 2",
}
local result = CALLBACK.sweep(200)
expect(CALLBACK.pending["OnPastaCallBack1"]):toBe(nil)
expect(CALLBACK.pending["OnPastaCallBack2"]):toBe(nil)
expect(result:find("500 Internal Server Error")).not_:toBe(nil)
expect(#warn_calls):toBe(2)
end)
test("sweep: タイムアウト 500 は RES.err 経路で X-Error-Reason 正準ヘッダーを出力する (3.49 G3)", function()
setup()
local co = coroutine.create(function() coroutine.yield() end)
coroutine.resume(co)
CALLBACK.pending["OnPastaCallBack1"] = {
co = co,
act = {},
timeout_at = 100,
on_timeout = "timeout via sweep",
}
local result = CALLBACK.sweep(200)
expect(result:find("500 Internal Server Error", 1, true)).not_:toBe(nil)
expect(result:find("X-Error-Reason: timeout via sweep", 1, true)).not_:toBe(nil)
expect(result:find("X-ERROR-REASON", 1, true)):toBe(nil)
expect(result:find("Charset: ", 1, true)).not_:toBe(nil)
expect(result:find("Sender: ", 1, true)).not_:toBe(nil)
expect(result:find("SecurityLevel: ", 1, true)).not_:toBe(nil)
end)
test("sweep: 空文字列 on_timeout は RES.err 規約で Unknown error に正規化される (3.49 G3)", function()
setup()
local co = coroutine.create(function() coroutine.yield() end)
coroutine.resume(co)
CALLBACK.pending["OnPastaCallBack1"] = {
co = co,
act = {},
timeout_at = 100,
on_timeout = "",
}
local result = CALLBACK.sweep(200)
expect(result:find("500 Internal Server Error", 1, true)).not_:toBe(nil)
expect(result:find("X-Error-Reason: Unknown error", 1, true)).not_:toBe(nil)
end)
test("handles mixed on_timeout types (string and nil)", function()
setup()
local co1 = coroutine.create(function() coroutine.yield() end)
coroutine.resume(co1)
local co2 = coroutine.create(function() coroutine.yield() end)
coroutine.resume(co2)
CALLBACK.pending["OnPastaCallBack1"] = {
co = co1,
act = {},
timeout_at = 50,
on_timeout = nil,
}
CALLBACK.pending["OnPastaCallBack2"] = {
co = co2,
act = {},
timeout_at = 60,
on_timeout = "timeout reason",
}
local result = CALLBACK.sweep(200)
expect(CALLBACK.pending["OnPastaCallBack1"]):toBe(nil)
expect(CALLBACK.pending["OnPastaCallBack2"]):toBe(nil)
expect(#warn_calls):toBe(1)
if result then
expect(result:find("500 Internal Server Error")).not_:toBe(nil)
end
end)
end)