Files
2026-07-13 12:32:21 +08:00

277 lines
9.2 KiB
Lua

--- Module implementing http_mock, a HTTP mocking server for testing.
-- @module spec.helpers.http_mock
local helpers = require "spec.helpers"
local pairs = pairs
local ipairs = ipairs
local type = type
local setmetatable = setmetatable
local modules = {
require "spec.helpers.http_mock.nginx_instance",
require "spec.helpers.http_mock.asserts",
require "spec.helpers.http_mock.debug_port",
require "spec.helpers.http_mock.clients",
}
local http_mock = {}
-- since http_mock contains a lot of functionality, it is implemented in separate submodules
-- and combined into one large http_mock module here.
for _, module in ipairs(modules) do
for k, v in pairs(module) do
http_mock[k] = v
end
end
-- get a session from the logs with a timeout
-- throws error if no request is recieved within the timeout
-- @treturn table the session
function http_mock:get_session()
local ret
self.eventually:has_session_satisfy(function(s)
ret = s
return true
end)
return ret
end
-- get a request from the logs with a timeout
-- throws error if no request is recieved within the timeout
-- @treturn table the request
function http_mock:get_request()
return self:get_session().req
end
-- get a response from the logs with a timeout
-- throws error if no request is recieved within the timeout
-- @treturn table the response
function http_mock:get_response()
return self:get_session().resp
end
local http_mock_MT = { __index = http_mock, __gc = http_mock.stop }
-- TODO: make default_mocking the same to the `mock_upstream`
local default_mocking = {
["/"] = {
access = [[
ngx.req.set_header("X-Test", "test")
ngx.print("ok")
ngx.exit(200)
]],
},
}
local function default_field(tbl, key, default)
if tbl[key] == nil then
tbl[key] = default
end
end
--- create a mock instance which represents a HTTP mocking server
-- @tparam[opt] table|string|number listens the listen directive of the mock server. This can be
-- a single directive (string), or a list of directives (table), or a number which will be used as the port.
-- Defaults to a random available port
-- @tparam[opt] table|string routes the code of the mock server, defaults to a simple response. See Examples.
-- @tparam[opt={}] table opts options for the mock server, supporting fields:
-- @tparam[opt="servroot_tapping"] string opts.prefix the prefix of the mock server
-- @tparam[opt="_"] string opts.hostname the hostname of the mock server
-- @tparam[opt=false] bool opts.tls whether to use tls
-- @tparam[opt={}] table opts.directives the extra directives of the mock server
-- @tparam[opt={}] table opts.log_opts the options for logging with fields listed below:
-- @tparam[opt=true] bool opts.log_opts.collect_req whether to log requests()
-- @tparam[opt=true] bool opts.log_opts.collect_req_body_large whether to log large request bodies
-- @tparam[opt=false] bool opts.log_opts.collect_resp whether to log responses
-- @tparam[opt=false] bool opts.log_opts.collect_resp_body whether to log response bodies
-- @tparam[opt=true] bool opts.log_opts.collect_err: whether to log errors
-- @tparam[opt] string opts.init: the lua code injected into the init_by_lua_block
-- @treturn http_mock a mock instance
-- @treturn string the port the mock server listens to
-- @usage
-- local mock = http_mock.new(8000, [[
-- ngx.req.set_header("X-Test", "test")
-- ngx.print("hello world")
-- ]], {
-- prefix = "mockserver",
-- log_opts = {
-- resp = true,
-- resp_body = true,
-- },
-- tls = true,
-- })
--
-- mock:start()
-- local client = mock:get_client() -- get a client to access the mocking port
-- local res = assert(client:send({}))
-- assert.response(res).has.status(200)
-- assert.response(res).has.header("X-Test", "test")
-- assert.response(res).has.body("hello world")
-- mock.eventually:has_response(function(resp)
-- assert.same(resp.body, "hello world")
-- end)
-- mock:wait_until_no_request() -- wait until all the requests are finished
-- mock:clean() -- clean the logs
-- client:send({})
-- client:send({})
-- local logs = mock:retrieve_mocking_logs() -- get all the logs of HTTP sessions
-- mock:stop()
--
-- listens can be a number, which will be used as the port of the mock server;
-- or a string, which will be used as the param of listen directive of the mock server;
-- or a table represents multiple listen ports.
-- if the port is not specified, a random port will be used.
-- call mock:get_default_port() to get the first port the mock server listens to.
-- if the port is a number and opts.tls is set to ture, ssl will be appended.
--
-- routes can be a table like this:
-- routes = {
-- ["/"] = {
-- access = [[
-- ngx.req.set_header("X-Test", "test")
-- ngx.print("hello world")
-- ]],
-- log = [[
-- ngx.log(ngx.ERR, "log test!")
-- ]],
-- directives = {
-- "rewrite ^/foo /bar break;",
-- },
-- },
-- }
--
-- -- or single a string, which will be used as the access phase handler.
-- routes = [[ ngx.print("hello world") ]]
-- -- which is equivalent to:
-- routes = {
-- ["/"] = {
-- access = [[ ngx.print("hello world") ]],
-- },
-- }
function http_mock.new(listens, routes, opts)
opts = opts or {}
if listens == nil then
listens = helpers.get_available_port()
end
if type(listens) == "number" then
listens = "0.0.0.0:" .. listens .. (opts.tls and " ssl" or "")
end
if type(listens) == "string" then
listens = { listens, }
end
if routes == nil then
routes = default_mocking
elseif type(routes) == "string" then
routes = {
["/"] = {
access = routes,
}
}
end
opts.log_opts = opts.log_opts or {}
local log_opts = opts.log_opts
default_field(log_opts, "req", true)
default_field(log_opts, "req_body_large", true)
-- usually we can check response from client side
default_field(log_opts, "resp", false)
default_field(log_opts, "resp_body", false)
default_field(log_opts, "err", true)
local prefix = opts.prefix or "servroot_mock"
local hostname = opts.hostname or "_"
local directives = opts.directives or {}
local _self = setmetatable({
prefix = prefix,
hostname = hostname,
listens = listens,
routes = routes,
directives = directives,
dicts = opts.dicts,
init = opts.init,
log_opts = log_opts,
logs = {},
tls = opts.tls,
eventually_timeout = opts.eventually_timeout or 5,
}, http_mock_MT)
local port = _self:get_default_port()
if port then
_self.client_opts = {
port = port,
tls = opts.tls,
}
end
_self:_set_eventually_table()
_self:_setup_debug()
return _self, port
end
--- @type http_mock
--- returns the default port of the mock server.
-- @function http_mock:get_default_port
-- @treturn string the port of the mock server (from the first listen directive)
function http_mock:get_default_port()
return self.listens[1]:match(":(%d+)")
end
--- retrieve the logs of HTTP sessions
-- @function http_mock:retrieve_mocking_logs
-- @treturn table the logs of HTTP sessions
--- purge the logs of HTTP sessions
-- @function http_mock:purge_mocking_logs
--- clean the logs of HTTP sessions
-- @function http_mock:clean
--- wait until all the requests are finished
-- @function http_mock:wait_until_no_request
-- @tparam[opt=true,default=5] number timeout the timeout to wait for the nginx process to exit
--- make assertions on HTTP requests.
-- with a timeout to wait for the requests to arrive
-- @table http_mock.eventually
--- assert if the condition is true for one of the logs.
--- Replace "session" in the name of the function to assert on fields of the log.
--- The field can be one of "session", "request", "response", "error".
-- @function http_mock.eventually:has_session_satisfy
-- @tparam function check the check function, accept a log and throw error if the condition is not satisfied
--- assert if the condition is true for all the logs.
-- Replace "session" in the name of the function to assert on fields of the log.
-- The field can be one of "session", "request", "response", "error".
-- @function http_mock.eventually:all_session_satisfy
-- @tparam function check the check function, accept a log and throw error if the condition is not satisfied
--- assert if none of the logs satisfy the condition.
-- Replace "session" in the name of the function to assert on fields of the log.
-- The field can be one of "session", "request", "response", "error".
-- @function http_mock.eventually:has_no_session_satisfy
-- @tparam function check the check function, accept a log and throw error if the condition is not satisfied
--- assert if not all the logs satisfy the condition.
-- Replace "session" in the name of the function to assert on fields of the log.
-- The field can be one of "session", "request", "response", "error".
-- @function http_mock.eventually:not_all_session_satisfy
-- @tparam function check the check function, accept a log and throw error if the condition is not satisfied
--- alias for eventually:not_all_{session,request,response,error}_satisfy.
-- Replace "session" in the name of the function to assert on fields of the log.
-- The field can be one of "session", "request", "response", "error".
-- @function http_mock.eventually:has_one_without_session_satisfy
-- @tparam function check the check function, accept a log and throw error if the condition is not satisfied
return http_mock