Page Menu
Home
Phabricator (Chris)
Search
Configure Global Search
Log In
Files
F116889
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
63 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/spec/core/permissions_spec.lua b/spec/core/permissions_spec.lua
index 9400e5c..91af8a2 100644
--- a/spec/core/permissions_spec.lua
+++ b/spec/core/permissions_spec.lua
@@ -1,250 +1,249 @@
--[[
Tests for src/core/permissions.lua
Tests is_global_admin, is_group_admin (with cache), check_bot_can,
can_restrict, can_delete, can_pin, is_admin_or_mod.
]]
describe('core.permissions', function()
local permissions
local mock_api_mod = require('spec.helpers.mock_api')
local mock_redis = require('spec.helpers.mock_redis')
local mock_db = require('spec.helpers.mock_db')
local api, redis, db
before_each(function()
-- Reset modules to get fresh state
package.loaded['src.core.permissions'] = nil
package.loaded['src.core.session'] = nil
package.loaded['src.core.config'] = nil
-- Mock config to return known admin list
package.loaded['src.core.config'] = {
get = function(key, default) return default end,
get_number = function(key, default) return default end,
is_enabled = function(key) return false end,
bot_admins = function() return { 221714512, 99999 } end,
bot_name = function() return 'mattata' end,
get_list = function(key) return { 221714512, 99999 } end,
load = function() end,
VERSION = '2.0',
}
redis = mock_redis.new()
-- Init session with our mock redis
local session = require('src.core.session')
session.init(redis)
permissions = require('src.core.permissions')
api = mock_api_mod.new()
db = mock_db.new()
end)
after_each(function()
api.reset()
redis.reset()
db.reset()
end)
describe('is_global_admin()', function()
it('should return true for a global admin', function()
assert.is_true(permissions.is_global_admin(221714512))
end)
it('should return true for second global admin', function()
assert.is_true(permissions.is_global_admin(99999))
end)
it('should return false for non-admin', function()
assert.is_false(permissions.is_global_admin(111111))
end)
it('should return false for nil', function()
assert.is_false(permissions.is_global_admin(nil))
end)
it('should handle string user_id by converting to number', function()
assert.is_true(permissions.is_global_admin('221714512'))
end)
it('should return false for non-numeric string', function()
assert.is_false(permissions.is_global_admin('abc'))
end)
end)
describe('is_group_admin()', function()
it('should return true for global admin without API call', function()
local result = permissions.is_group_admin(api, -100123, 221714512)
assert.is_true(result)
-- Should not have called get_chat_member since user is global admin
assert.are.equal(0, api.count_calls('get_chat_member'))
end)
it('should return true for Telegram administrator', function()
api.set_admin(-100123, 456)
local result = permissions.is_group_admin(api, -100123, 456)
assert.is_true(result)
end)
it('should return true for chat creator', function()
api.set_creator(-100123, 789)
local result = permissions.is_group_admin(api, -100123, 789)
assert.is_true(result)
end)
it('should return false for regular member', function()
local result = permissions.is_group_admin(api, -100123, 111)
assert.is_false(result)
end)
it('should cache admin status in session', function()
api.set_admin(-100123, 456)
permissions.is_group_admin(api, -100123, 456)
-- Second call should use cache, not API
api.reset()
local result = permissions.is_group_admin(api, -100123, 456)
assert.is_true(result)
assert.are.equal(0, api.count_calls('get_chat_member'))
end)
it('should cache non-admin status too', function()
permissions.is_group_admin(api, -100123, 111)
api.reset()
local result = permissions.is_group_admin(api, -100123, 111)
assert.is_false(result)
assert.are.equal(0, api.count_calls('get_chat_member'))
end)
it('should return false when chat_id is nil', function()
assert.is_false(permissions.is_group_admin(api, nil, 456))
end)
it('should return false when user_id is nil', function()
assert.is_false(permissions.is_group_admin(api, -100123, nil))
end)
end)
describe('is_group_mod()', function()
it('should return true when user has moderator role', function()
db.set_next_result({ { ['1'] = 1 } })
assert.is_true(permissions.is_group_mod(db, -100123, 456))
end)
it('should return false when user is not a moderator', function()
db.set_next_result({})
assert.is_false(permissions.is_group_mod(db, -100123, 456))
end)
it('should return false for nil chat_id', function()
assert.is_false(permissions.is_group_mod(db, nil, 456))
end)
it('should return false for nil user_id', function()
assert.is_false(permissions.is_group_mod(db, -100123, nil))
end)
it('should query the correct SQL', function()
db.set_next_result({})
permissions.is_group_mod(db, -100123, 456)
- assert.is_true(db.has_query('chat_members'))
- assert.is_true(db.has_query('moderator'))
+ assert.is_true(db.has_query('sp_check_group_moderator'))
end)
end)
describe('is_trusted()', function()
it('should return true when user has trusted role', function()
db.set_next_result({ { ['1'] = 1 } })
assert.is_true(permissions.is_trusted(db, -100123, 456))
end)
it('should return false when user is not trusted', function()
db.set_next_result({})
assert.is_false(permissions.is_trusted(db, -100123, 456))
end)
end)
describe('check_bot_can()', function()
it('should return true when bot has the permission', function()
api.set_bot_admin(-100123, { can_restrict_members = true })
local result = permissions.check_bot_can(api, -100123, 'can_restrict_members')
assert.is_true(result)
end)
it('should return false when bot lacks the permission', function()
api.set_bot_admin(-100123, { can_restrict_members = false })
local result = permissions.check_bot_can(api, -100123, 'can_restrict_members')
assert.is_false(result)
end)
it('should return false when bot is not an admin', function()
-- Default: bot is a regular member
local result = permissions.check_bot_can(api, -100123, 'can_restrict_members')
assert.is_false(result)
end)
it('should cache the result', function()
api.set_bot_admin(-100123, { can_restrict_members = true })
permissions.check_bot_can(api, -100123, 'can_restrict_members')
api.reset()
local result = permissions.check_bot_can(api, -100123, 'can_restrict_members')
assert.is_true(result)
assert.are.equal(0, api.count_calls('get_chat_member'))
end)
it('should return false for nil chat_id', function()
assert.is_false(permissions.check_bot_can(api, nil, 'can_restrict_members'))
end)
it('should return false for nil permission', function()
assert.is_false(permissions.check_bot_can(api, -100123, nil))
end)
end)
describe('convenience permission checks', function()
it('can_restrict should check can_restrict_members', function()
api.set_bot_admin(-100123, { can_restrict_members = true })
assert.is_true(permissions.can_restrict(api, -100123))
end)
it('can_delete should check can_delete_messages', function()
api.set_bot_admin(-100123, { can_delete_messages = true })
assert.is_true(permissions.can_delete(api, -100123))
end)
it('can_pin should check can_pin_messages', function()
api.set_bot_admin(-100123, { can_pin_messages = true })
assert.is_true(permissions.can_pin(api, -100123))
end)
it('can_promote should check can_promote_members', function()
api.set_bot_admin(-100123, { can_promote_members = true })
assert.is_true(permissions.can_promote(api, -100123))
end)
it('can_invite should check can_invite_users', function()
api.set_bot_admin(-100123, { can_invite_users = true })
assert.is_true(permissions.can_invite(api, -100123))
end)
end)
describe('is_admin_or_mod()', function()
it('should return true for admin', function()
api.set_admin(-100123, 456)
assert.is_true(permissions.is_admin_or_mod(api, db, -100123, 456))
end)
it('should return true for moderator', function()
db.set_next_result({ { ['1'] = 1 } })
assert.is_true(permissions.is_admin_or_mod(api, db, -100123, 789))
end)
it('should return false for regular user', function()
db.set_next_result({})
assert.is_false(permissions.is_admin_or_mod(api, db, -100123, 111))
end)
it('should return true for global admin', function()
assert.is_true(permissions.is_admin_or_mod(api, db, -100123, 221714512))
end)
end)
end)
diff --git a/spec/helpers/mock_db.lua b/spec/helpers/mock_db.lua
index f82b163..4b046ad 100644
--- a/spec/helpers/mock_db.lua
+++ b/spec/helpers/mock_db.lua
@@ -1,108 +1,123 @@
--[[
mattata v2.0 - Mock PostgreSQL Database
Records queries and returns configurable results for testing.
]]
local mock_db = {}
function mock_db.new()
local db = {
queries = {},
data = {}, -- table_name -> array of rows
next_result = nil,
result_queue = {}, -- FIFO queue of results to return
}
function db.query(sql)
table.insert(db.queries, { sql = sql })
if db.next_result then
local r = db.next_result
db.next_result = nil
return r
end
if #db.result_queue > 0 then
return table.remove(db.result_queue, 1)
end
return {}
end
function db.execute(sql, params)
table.insert(db.queries, { sql = sql, params = params })
if db.next_result then
local r = db.next_result
db.next_result = nil
return r
end
if #db.result_queue > 0 then
return table.remove(db.result_queue, 1)
end
return {}
end
function db.insert(table_name, data_row)
table.insert(db.queries, { op = 'insert', table_name = table_name, data = data_row })
if not db.data[table_name] then db.data[table_name] = {} end
table.insert(db.data[table_name], data_row)
if db.next_result then
local r = db.next_result
db.next_result = nil
return r
end
return { data_row }
end
function db.upsert(table_name, data_row, conflict_keys, update_keys)
table.insert(db.queries, { op = 'upsert', table_name = table_name, data = data_row, conflict_keys = conflict_keys, update_keys = update_keys })
if not db.data[table_name] then db.data[table_name] = {} end
table.insert(db.data[table_name], data_row)
if db.next_result then
local r = db.next_result
db.next_result = nil
return r
end
return { data_row }
end
+ -- Stored procedure call: records func_name, params, and a synthetic sql for has_query
+ function db.call(func_name, params)
+ local sql = 'SELECT * FROM ' .. func_name .. '(...)'
+ table.insert(db.queries, { op = 'call', func_name = func_name, params = params, sql = sql })
+ if db.next_result then
+ local r = db.next_result
+ db.next_result = nil
+ return r
+ end
+ if #db.result_queue > 0 then
+ return table.remove(db.result_queue, 1)
+ end
+ return {}
+ end
+
function db.set_next_result(result)
db.next_result = result
end
-- Queue multiple results (consumed in order)
function db.queue_result(result)
table.insert(db.result_queue, result)
end
function db.transaction(fn)
return fn(db.query, db.execute)
end
function db.pool_stats()
return { available = 5, max_size = 10 }
end
function db.reset()
db.queries = {}
db.data = {}
db.next_result = nil
db.result_queue = {}
end
-- Helper: check if a query matching pattern was executed
function db.has_query(pattern)
for _, q in ipairs(db.queries) do
if q.sql and q.sql:match(pattern) then
return true
end
end
return false
end
-- Helper: get the last query
function db.last_query()
return db.queries[#db.queries]
end
return db
end
return mock_db
diff --git a/spec/middleware/stats_spec.lua b/spec/middleware/stats_spec.lua
index e3043f5..a42a724 100644
--- a/spec/middleware/stats_spec.lua
+++ b/spec/middleware/stats_spec.lua
@@ -1,247 +1,247 @@
--[[
Tests for src/middleware/stats.lua
Tests message counter increment, command tracking, flush to PostgreSQL.
]]
describe('middleware.stats', function()
local stats_mw
local test_helper = require('spec.helpers.test_helper')
local env, ctx, message
before_each(function()
package.loaded['src.middleware.stats'] = nil
package.loaded['src.core.logger'] = {
debug = function() end,
info = function() end,
warn = function() end,
error = function() end,
}
stats_mw = require('src.middleware.stats')
env = test_helper.setup()
message = test_helper.make_message()
ctx = test_helper.make_ctx(env)
end)
after_each(function()
test_helper.teardown(env)
end)
describe('name', function()
it('should be "stats"', function()
assert.are.equal('stats', stats_mw.name)
end)
end)
describe('when message has no from or chat', function()
it('should continue when no from', function()
message.from = nil
local _, should_continue = stats_mw.run(ctx, message)
assert.is_true(should_continue)
end)
it('should continue when no chat', function()
message.chat = nil
local _, should_continue = stats_mw.run(ctx, message)
assert.is_true(should_continue)
end)
it('should not increment counters when no from', function()
message.from = nil
stats_mw.run(ctx, message)
assert.are.equal(0, #env.redis.commands)
end)
end)
describe('message counter', function()
it('should increment message counter in Redis', function()
stats_mw.run(ctx, message)
-- Check that an incr command was issued
assert.is_true(env.redis.has_command('incr'))
end)
it('should use correct key format', function()
stats_mw.run(ctx, message)
local date = os.date('!%Y-%m-%d')
local expected_prefix = 'stats:msg:' .. message.chat.id .. ':' .. date
local found = false
for k in pairs(env.redis.store) do
if type(k) == 'string' and k:match('^stats:msg:') then
found = true
end
end
assert.is_true(found)
end)
it('should set 24h TTL on first increment', function()
stats_mw.run(ctx, message)
-- Find the stats key
for k, ttl in pairs(env.redis.ttls) do
if type(k) == 'string' and k:match('^stats:msg:') then
assert.are.equal(86400, ttl)
end
end
end)
end)
describe('command tracking', function()
it('should track command usage for / prefixed messages', function()
message.text = '/ping'
stats_mw.run(ctx, message)
local found = false
for k in pairs(env.redis.store) do
if type(k) == 'string' and k:match('^stats:cmd:ping:') then
found = true
end
end
assert.is_true(found)
end)
it('should track command usage for ! prefixed messages', function()
message.text = '!help'
stats_mw.run(ctx, message)
local found = false
for k in pairs(env.redis.store) do
if type(k) == 'string' and k:match('^stats:cmd:help:') then
found = true
end
end
assert.is_true(found)
end)
it('should track command usage for # prefixed messages', function()
message.text = '#ban user'
stats_mw.run(ctx, message)
local found = false
for k in pairs(env.redis.store) do
if type(k) == 'string' and k:match('^stats:cmd:ban:') then
found = true
end
end
assert.is_true(found)
end)
it('should lowercase command names', function()
message.text = '/PING'
stats_mw.run(ctx, message)
local found = false
for k in pairs(env.redis.store) do
if type(k) == 'string' and k:match('^stats:cmd:ping:') then
found = true
end
end
assert.is_true(found)
end)
it('should not track non-command messages', function()
message.text = 'hello world'
stats_mw.run(ctx, message)
local found = false
for k in pairs(env.redis.store) do
if type(k) == 'string' and k:match('^stats:cmd:') then
found = true
end
end
assert.is_false(found)
end)
it('should set 24h TTL on first command counter', function()
message.text = '/ping'
stats_mw.run(ctx, message)
for k, ttl in pairs(env.redis.ttls) do
if type(k) == 'string' and k:match('^stats:cmd:') then
assert.are.equal(86400, ttl)
end
end
end)
end)
describe('always continues', function()
it('should always return true', function()
local _, should_continue = stats_mw.run(ctx, message)
assert.is_true(should_continue)
end)
end)
describe('flush()', function()
it('should flush message stats to PostgreSQL', function()
-- Set up some stats keys
local date = os.date('!%Y-%m-%d')
env.redis.set('stats:msg:-100123:' .. date .. ':456', '10')
env.redis.set('stats:msg:-100123:' .. date .. ':789', '5')
stats_mw.flush(env.db, env.redis)
- -- Should have executed SQL insert/upsert
- local sql_count = 0
+ -- Should have called sp_flush_message_stats
+ local call_count = 0
for _, q in ipairs(env.db.queries) do
- if q.sql and q.sql:match('message_stats') then
- sql_count = sql_count + 1
+ if q.op == 'call' and q.func_name == 'sp_flush_message_stats' then
+ call_count = call_count + 1
end
end
- assert.is_true(sql_count > 0)
+ assert.is_true(call_count > 0)
end)
it('should flush command stats to PostgreSQL', function()
local date = os.date('!%Y-%m-%d')
env.redis.set('stats:cmd:ping:-100123:' .. date, '25')
stats_mw.flush(env.db, env.redis)
- local sql_count = 0
+ local call_count = 0
for _, q in ipairs(env.db.queries) do
- if q.sql and q.sql:match('command_stats') then
- sql_count = sql_count + 1
+ if q.op == 'call' and q.func_name == 'sp_flush_command_stats' then
+ call_count = call_count + 1
end
end
- assert.is_true(sql_count > 0)
+ assert.is_true(call_count > 0)
end)
it('should delete Redis keys after flushing', function()
local date = os.date('!%Y-%m-%d')
local key = 'stats:msg:-100123:' .. date .. ':456'
env.redis.set(key, '10')
stats_mw.flush(env.db, env.redis)
assert.is_nil(env.redis.store[key])
end)
it('should skip keys with zero count', function()
local date = os.date('!%Y-%m-%d')
env.redis.set('stats:msg:-100123:' .. date .. ':456', '0')
stats_mw.flush(env.db, env.redis)
- -- Should not have executed any message_stats SQL
- local sql_count = 0
+ -- Should not have called sp_flush_message_stats
+ local call_count = 0
for _, q in ipairs(env.db.queries) do
- if q.sql and q.sql:match('message_stats') then
- sql_count = sql_count + 1
+ if q.op == 'call' and q.func_name == 'sp_flush_message_stats' then
+ call_count = call_count + 1
end
end
- assert.are.equal(0, sql_count)
+ assert.are.equal(0, call_count)
end)
it('should handle empty stats gracefully', function()
assert.has_no.errors(function()
stats_mw.flush(env.db, env.redis)
end)
end)
- it('should use ON CONFLICT upsert SQL', function()
+ it('should call stored procedures for flush', function()
local date = os.date('!%Y-%m-%d')
env.redis.set('stats:msg:-100123:' .. date .. ':456', '10')
stats_mw.flush(env.db, env.redis)
local found = false
for _, q in ipairs(env.db.queries) do
- if q.sql and q.sql:match('ON CONFLICT') then
+ if q.op == 'call' and q.func_name:match('^sp_flush_') then
found = true
end
end
assert.is_true(found)
end)
end)
end)
diff --git a/spec/middleware/user_tracker_spec.lua b/spec/middleware/user_tracker_spec.lua
index 3d374f6..3a99d05 100644
--- a/spec/middleware/user_tracker_spec.lua
+++ b/spec/middleware/user_tracker_spec.lua
@@ -1,221 +1,217 @@
--[[
Tests for src/middleware/user_tracker.lua
Tests debouncing, upsert on first message, username mapping.
]]
describe('middleware.user_tracker', function()
local user_tracker
local test_helper = require('spec.helpers.test_helper')
local env, ctx, message
before_each(function()
package.loaded['src.middleware.user_tracker'] = nil
package.loaded['src.core.logger'] = {
debug = function() end,
info = function() end,
warn = function() end,
error = function() end,
}
user_tracker = require('src.middleware.user_tracker')
env = test_helper.setup()
message = test_helper.make_message()
ctx = test_helper.make_ctx(env)
end)
after_each(function()
test_helper.teardown(env)
end)
describe('name', function()
it('should be "user_tracker"', function()
assert.are.equal('user_tracker', user_tracker.name)
end)
end)
describe('when message has no from', function()
it('should continue processing', function()
message.from = nil
local new_ctx, should_continue = user_tracker.run(ctx, message)
assert.is_true(should_continue)
end)
end)
describe('debouncing', function()
it('should skip DB upsert when dedup key exists', function()
-- Set dedup key to simulate recent activity
local dedup_key = string.format('seen:%s:%s', message.from.id, message.chat.id)
env.redis.set(dedup_key, '1')
user_tracker.run(ctx, message)
- -- Should NOT have done any upserts
- local upsert_count = 0
+ -- Should NOT have done any calls
+ local call_count = 0
for _, q in ipairs(env.db.queries) do
- if q.op == 'upsert' then upsert_count = upsert_count + 1 end
+ if q.op == 'call' then call_count = call_count + 1 end
end
- assert.are.equal(0, upsert_count)
+ assert.are.equal(0, call_count)
end)
it('should still update username mapping when debounced', function()
local dedup_key = string.format('seen:%s:%s', message.from.id, message.chat.id)
env.redis.set(dedup_key, '1')
user_tracker.run(ctx, message)
-- Should have set username mapping
assert.is_not_nil(env.redis.store['username:testuser'])
end)
it('should upsert on first message (no dedup key)', function()
user_tracker.run(ctx, message)
- -- Should have upserted user
+ -- Should have called sp_upsert_user
local user_upserted = false
for _, q in ipairs(env.db.queries) do
- if q.op == 'upsert' and q.table_name == 'users' then
+ if q.op == 'call' and q.func_name == 'sp_upsert_user' then
user_upserted = true
end
end
assert.is_true(user_upserted)
end)
it('should set dedup key with 60s TTL', function()
user_tracker.run(ctx, message)
local dedup_key = string.format('seen:%s:%s', message.from.id, message.chat.id)
assert.is_not_nil(env.redis.store[dedup_key])
assert.are.equal(60, env.redis.ttls[dedup_key])
end)
end)
describe('user upsert', function()
it('should upsert user data to PostgreSQL', function()
user_tracker.run(ctx, message)
local found = false
for _, q in ipairs(env.db.queries) do
- if q.op == 'upsert' and q.table_name == 'users' then
+ if q.op == 'call' and q.func_name == 'sp_upsert_user' then
found = true
- assert.are.equal(message.from.id, q.data.user_id)
- assert.are.equal('testuser', q.data.username)
- assert.are.equal('Test', q.data.first_name)
+ -- params: user_id, username, first_name, last_name, language_code, is_bot, last_seen
+ assert.are.equal(message.from.id, q.params[1])
+ assert.are.equal('testuser', q.params[2])
+ assert.are.equal('Test', q.params[3])
end
end
assert.is_true(found)
end)
it('should handle users without username', function()
message.from.username = nil
user_tracker.run(ctx, message)
for _, q in ipairs(env.db.queries) do
- if q.op == 'upsert' and q.table_name == 'users' then
- assert.is_nil(q.data.username)
+ if q.op == 'call' and q.func_name == 'sp_upsert_user' then
+ assert.is_nil(q.params[2])
end
end
end)
it('should lowercase username', function()
message.from.username = 'TestUser'
user_tracker.run(ctx, message)
for _, q in ipairs(env.db.queries) do
- if q.op == 'upsert' and q.table_name == 'users' then
- assert.are.equal('testuser', q.data.username)
+ if q.op == 'call' and q.func_name == 'sp_upsert_user' then
+ assert.are.equal('testuser', q.params[2])
end
end
end)
- it('should use correct conflict and update keys', function()
+ it('should pass all required fields', function()
user_tracker.run(ctx, message)
for _, q in ipairs(env.db.queries) do
- if q.op == 'upsert' and q.table_name == 'users' then
- assert.are.same({ 'user_id' }, q.conflict_keys)
- -- Update keys should include username, first_name, etc.
- local has_username = false
- for _, k in ipairs(q.update_keys) do
- if k == 'username' then has_username = true end
- end
- assert.is_true(has_username)
+ if q.op == 'call' and q.func_name == 'sp_upsert_user' then
+ -- 7 params: user_id, username, first_name, last_name, language_code, is_bot, last_seen
+ assert.are.equal(7, q.params.n or #q.params)
end
end
end)
end)
describe('chat upsert', function()
it('should upsert chat data for group messages', function()
user_tracker.run(ctx, message)
local found = false
for _, q in ipairs(env.db.queries) do
- if q.op == 'upsert' and q.table_name == 'chats' then
+ if q.op == 'call' and q.func_name == 'sp_upsert_chat' then
found = true
- assert.are.equal(message.chat.id, q.data.chat_id)
- assert.are.equal('Test Group', q.data.title)
+ assert.are.equal(message.chat.id, q.params[1])
+ assert.are.equal('Test Group', q.params[2])
end
end
assert.is_true(found)
end)
it('should not upsert chat for private messages', function()
message.chat.type = 'private'
user_tracker.run(ctx, message)
for _, q in ipairs(env.db.queries) do
- if q.op == 'upsert' and q.table_name == 'chats' then
+ if q.op == 'call' and q.func_name == 'sp_upsert_chat' then
assert.fail('should not upsert chat for private messages')
end
end
end)
it('should track user-chat membership', function()
user_tracker.run(ctx, message)
local found = false
for _, q in ipairs(env.db.queries) do
- if q.op == 'upsert' and q.table_name == 'chat_members' then
+ if q.op == 'call' and q.func_name == 'sp_upsert_chat_member' then
found = true
- assert.are.equal(message.chat.id, q.data.chat_id)
- assert.are.equal(message.from.id, q.data.user_id)
+ assert.are.equal(message.chat.id, q.params[1])
+ assert.are.equal(message.from.id, q.params[2])
end
end
assert.is_true(found)
end)
end)
describe('username mapping', function()
it('should set username -> user_id mapping in Redis', function()
user_tracker.run(ctx, message)
local stored_id = env.redis.store['username:testuser']
assert.is_not_nil(stored_id)
assert.are.equal(tostring(message.from.id), stored_id)
end)
it('should not set mapping when username is nil', function()
message.from.username = nil
user_tracker.run(ctx, message)
-- No username:* key should exist
local found = false
for k in pairs(env.redis.store) do
if k:match('^username:') then found = true end
end
assert.is_false(found)
end)
it('should lowercase username in mapping', function()
message.from.username = 'TestUser'
user_tracker.run(ctx, message)
assert.is_not_nil(env.redis.store['username:testuser'])
assert.is_nil(env.redis.store['username:TestUser'])
end)
end)
describe('always continues', function()
it('should always return true for should_continue', function()
local _, should_continue = user_tracker.run(ctx, message)
assert.is_true(should_continue)
end)
end)
end)
diff --git a/spec/plugins/admin/ban_spec.lua b/spec/plugins/admin/ban_spec.lua
index 5e15f5d..73700fd 100644
--- a/spec/plugins/admin/ban_spec.lua
+++ b/spec/plugins/admin/ban_spec.lua
@@ -1,296 +1,296 @@
--[[
Tests for src/plugins/admin/ban.lua
Tests target resolution (reply, args, username), admin check, bot permission check,
ban execution, and logging.
]]
describe('plugins.admin.ban', function()
local ban_plugin
local test_helper = require('spec.helpers.test_helper')
local env, ctx, message
before_each(function()
-- Mock dependencies
package.loaded['src.plugins.admin.ban'] = nil
package.loaded['src.core.logger'] = {
debug = function() end,
info = function() end,
warn = function() end,
error = function() end,
}
package.loaded['src.core.config'] = {
get = function(key, default) return default end,
is_enabled = function() return false end,
bot_admins = function() return {} end,
load = function() end,
VERSION = '2.0',
}
package.loaded['src.core.session'] = {
get_admin_status = function() return nil end,
set_admin_status = function() end,
get_cached_setting = function(chat_id, key, fetch_fn, ttl)
return fetch_fn()
end,
}
package.loaded['src.core.permissions'] = {
is_global_admin = function() return false end,
is_group_admin = function(api, chat_id, user_id)
-- Target user is not admin by default
return false
end,
can_restrict = function(api, chat_id)
-- Bot can restrict by default in tests
return true
end,
}
-- Mock telegram-bot-lua.tools
package.loaded['telegram-bot-lua.tools'] = {
escape_html = function(text)
if not text then return '' end
return tostring(text):gsub('&', '&'):gsub('<', '<'):gsub('>', '>')
end,
}
ban_plugin = require('src.plugins.admin.ban')
env = test_helper.setup()
message = test_helper.make_message({
text = '/ban',
command = 'ban',
})
ctx = test_helper.make_ctx(env)
end)
after_each(function()
test_helper.teardown(env)
end)
describe('plugin metadata', function()
it('should have name "ban"', function()
assert.are.equal('ban', ban_plugin.name)
end)
it('should be in admin category', function()
assert.are.equal('admin', ban_plugin.category)
end)
it('should be group_only', function()
assert.is_true(ban_plugin.group_only)
end)
it('should be admin_only', function()
assert.is_true(ban_plugin.admin_only)
end)
it('should have ban and b commands', function()
assert.are.same({ 'ban', 'b' }, ban_plugin.commands)
end)
it('should have a help string', function()
assert.is_truthy(ban_plugin.help)
assert.is_truthy(ban_plugin.help:match('/ban'))
end)
end)
describe('bot permission check', function()
it('should error when bot lacks restrict permission', function()
package.loaded['src.core.permissions'].can_restrict = function() return false end
-- Reload plugin to pick up new mock
package.loaded['src.plugins.admin.ban'] = nil
ban_plugin = require('src.plugins.admin.ban')
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'Ban Users')
end)
end)
describe('target resolution', function()
it('should prompt when no target specified', function()
message.args = nil
message.reply = nil
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'specify')
end)
it('should resolve target from reply', function()
message.reply = {
from = { id = 222222, first_name = 'Target' },
message_id = 50,
}
message.args = nil
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_called(env.api, 'ban_chat_member')
local call = env.api.get_call('ban_chat_member')
assert.are.equal(222222, call.args[2])
end)
it('should resolve target from user ID in args', function()
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_called(env.api, 'ban_chat_member')
end)
it('should resolve target from username in args', function()
env.redis.set('username:targetuser', '333333')
message.args = '@targetuser'
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_called(env.api, 'ban_chat_member')
local call = env.api.get_call('ban_chat_member')
assert.are.equal(333333, call.args[2])
end)
it('should extract reason from args after user ID', function()
message.args = '222222 spamming links'
ban_plugin.on_message(env.api, message, ctx)
-- Check that reason was logged to DB
local found = false
for _, q in ipairs(env.db.queries) do
- if q.op == 'insert' and q.table_name == 'bans' then
+ if q.op == 'call' and q.func_name == 'sp_insert_ban' then
found = true
- assert.are.equal('spamming links', q.data.reason)
+ assert.are.equal('spamming links', q.params[4])
end
end
assert.is_true(found)
end)
it('should extract reason from args when replying', function()
message.reply = {
from = { id = 222222, first_name = 'Target' },
message_id = 50,
}
message.args = 'being disruptive'
ban_plugin.on_message(env.api, message, ctx)
local found = false
for _, q in ipairs(env.db.queries) do
- if q.op == 'insert' and q.table_name == 'bans' then
+ if q.op == 'call' and q.func_name == 'sp_insert_ban' then
found = true
- assert.are.equal('being disruptive', q.data.reason)
+ assert.are.equal('being disruptive', q.params[4])
end
end
assert.is_true(found)
end)
it('should strip "for" prefix from reason', function()
message.args = '222222 for spamming'
ban_plugin.on_message(env.api, message, ctx)
local found = false
for _, q in ipairs(env.db.queries) do
- if q.op == 'insert' and q.table_name == 'bans' then
+ if q.op == 'call' and q.func_name == 'sp_insert_ban' then
found = true
- assert.are.equal('spamming', q.data.reason)
+ assert.are.equal('spamming', q.params[4])
end
end
assert.is_true(found)
end)
it('should not ban the bot itself', function()
message.args = tostring(env.api.info.id)
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_not_called(env.api, 'ban_chat_member')
end)
end)
describe('admin target check', function()
it('should not ban an admin', function()
package.loaded['src.core.permissions'].is_group_admin = function(api, chat_id, user_id)
if user_id == 222222 then return true end
return false
end
package.loaded['src.plugins.admin.ban'] = nil
ban_plugin = require('src.plugins.admin.ban')
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_not_called(env.api, 'ban_chat_member')
test_helper.assert_sent_message_matches(env.api, "can't ban")
end)
end)
describe('ban execution', function()
it('should call ban_chat_member with correct chat and user', function()
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
local call = env.api.get_call('ban_chat_member')
assert.is_not_nil(call)
assert.are.equal(message.chat.id, call.args[1])
assert.are.equal(222222, call.args[2])
end)
it('should send success message with HTML', function()
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
local calls = env.api.get_calls('send_message')
-- Find the success message (not the prompt)
local found = false
for _, call in ipairs(calls) do
if call.args[2]:match('has banned') then
found = true
assert.are.equal('html', call.args[3])
end
end
assert.is_true(found)
end)
end)
describe('logging', function()
it('should log ban to bans table', function()
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
local found = false
for _, q in ipairs(env.db.queries) do
- if q.op == 'insert' and q.table_name == 'bans' then
+ if q.op == 'call' and q.func_name == 'sp_insert_ban' then
found = true
- assert.are.equal(message.chat.id, q.data.chat_id)
- assert.are.equal(222222, q.data.user_id)
- assert.are.equal(message.from.id, q.data.banned_by)
+ assert.are.equal(message.chat.id, q.params[1])
+ assert.are.equal(222222, q.params[2])
+ assert.are.equal(message.from.id, q.params[3])
end
end
assert.is_true(found)
end)
it('should log ban to admin_actions table', function()
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
local found = false
for _, q in ipairs(env.db.queries) do
- if q.op == 'insert' and q.table_name == 'admin_actions' then
+ if q.op == 'call' and q.func_name == 'sp_log_admin_action' then
found = true
- assert.are.equal('ban', q.data.action)
- assert.are.equal(message.from.id, q.data.admin_id)
- assert.are.equal(222222, q.data.target_id)
+ assert.are.equal('ban', q.params[4])
+ assert.are.equal(message.from.id, q.params[2])
+ assert.are.equal(222222, q.params[3])
end
end
assert.is_true(found)
end)
end)
describe('message cleanup', function()
it('should delete the command message', function()
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
-- Should have called delete_message for the command
local found = false
for _, call in ipairs(env.api.calls) do
if call.method == 'delete_message' and call.args[2] == message.message_id then
found = true
end
end
assert.is_true(found)
end)
it('should delete the replied-to message', function()
message.reply = {
from = { id = 222222, first_name = 'Target' },
message_id = 50,
}
message.args = nil
ban_plugin.on_message(env.api, message, ctx)
local found = false
for _, call in ipairs(env.api.calls) do
if call.method == 'delete_message' and call.args[2] == 50 then
found = true
end
end
assert.is_true(found)
end)
end)
end)
diff --git a/spec/plugins/admin/federation_spec.lua b/spec/plugins/admin/federation_spec.lua
index 9553857..7caad06 100644
--- a/spec/plugins/admin/federation_spec.lua
+++ b/spec/plugins/admin/federation_spec.lua
@@ -1,427 +1,427 @@
--[[
Tests for federation plugins: newfed, joinfed, fban, unfban, fbaninfo.
]]
describe('plugins.admin.federation', function()
local test_helper = require('spec.helpers.test_helper')
local env, ctx, message
before_each(function()
-- Mock shared dependencies
package.loaded['telegram-bot-lua.tools'] = {
escape_html = function(text)
if not text then return '' end
return tostring(text):gsub('&', '&'):gsub('<', '<'):gsub('>', '>')
end,
}
package.loaded['src.core.logger'] = {
debug = function() end, info = function() end,
warn = function() end, error = function() end,
}
package.loaded['src.core.config'] = {
get = function(key, default) return default end,
is_enabled = function() return false end,
bot_admins = function() return {} end,
load = function() end, VERSION = '2.0',
}
package.loaded['src.core.session'] = {
get_admin_status = function() return nil end,
set_admin_status = function() end,
get_cached_setting = function(chat_id, key, fetch_fn, ttl)
return fetch_fn()
end,
}
package.loaded['src.core.permissions'] = {
is_global_admin = function() return false end,
is_group_admin = function() return false end,
can_restrict = function() return true end,
}
env = test_helper.setup()
message = test_helper.make_message()
ctx = test_helper.make_ctx(env)
end)
after_each(function()
test_helper.teardown(env)
end)
describe('newfed', function()
local newfed
before_each(function()
package.loaded['src.plugins.admin.federation.newfed'] = nil
newfed = require('src.plugins.admin.federation.newfed')
end)
it('should have correct metadata', function()
assert.are.equal('newfed', newfed.name)
assert.are.same({ 'newfed' }, newfed.commands)
end)
it('should require a name argument', function()
message.args = nil
newfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'specify a name')
end)
it('should require a name argument when empty string', function()
message.args = ''
newfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'specify a name')
end)
it('should reject names longer than 128 characters', function()
message.args = string.rep('a', 129)
newfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, '128 characters')
end)
it('should allow names of exactly 128 characters', function()
message.args = string.rep('a', 128)
env.db.queue_result({ { count = 0 } }) -- existing count
env.db.queue_result({ { id = 'test-uuid' } }) -- insert result
newfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'created successfully')
end)
it('should reject when user already owns 5 federations', function()
message.args = 'Test Fed'
env.db.set_next_result({ { count = 5 } })
newfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'maximum')
end)
it('should create federation and return ID', function()
message.args = 'My Federation'
env.db.queue_result({ { count = 0 } })
env.db.queue_result({ { id = 'uuid-1234' } })
newfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'created successfully')
test_helper.assert_sent_message_matches(env.api, 'uuid%-1234')
end)
it('should handle DB failure gracefully', function()
message.args = 'Test Fed'
env.db.queue_result({ { count = 0 } })
env.db.queue_result({}) -- empty result = failure
newfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'Failed')
end)
end)
describe('joinfed', function()
local joinfed
before_each(function()
package.loaded['src.plugins.admin.federation.joinfed'] = nil
joinfed = require('src.plugins.admin.federation.joinfed')
end)
it('should have correct metadata', function()
assert.are.equal('joinfed', joinfed.name)
assert.is_true(joinfed.group_only)
assert.is_true(joinfed.admin_only)
end)
it('should require federation ID argument', function()
message.args = nil
joinfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'specify the federation ID')
end)
it('should reject when chat is already in a federation', function()
message.args = 'new-fed-id'
env.db.set_next_result({ { id = 'old-fed-id', name = 'Old Fed' } })
joinfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'already part')
end)
it('should reject when federation does not exist', function()
message.args = 'nonexistent-id'
env.db.queue_result({}) -- not in federation
env.db.queue_result({}) -- federation not found
joinfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'not found')
end)
it('should successfully join a federation', function()
message.args = 'fed-uuid'
env.db.queue_result({}) -- not in federation
env.db.queue_result({ { id = 'fed-uuid', name = 'Test Fed' } }) -- fed exists
env.db.queue_result({ {} }) -- insert result
joinfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'has joined')
end)
end)
describe('fban', function()
local fban
before_each(function()
package.loaded['src.plugins.admin.federation.fban'] = nil
fban = require('src.plugins.admin.federation.fban')
end)
it('should have correct metadata', function()
assert.are.equal('fban', fban.name)
assert.are.same({ 'fban' }, fban.commands)
end)
it('should require chat to be in a federation', function()
env.db.set_next_result({}) -- no federation
fban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'not part of any federation')
end)
it('should require federation admin/owner permission', function()
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 999 } })
env.db.queue_result({}) -- not a fed admin
message.from.id = 111111
fban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'federation owner or a federation admin')
end)
it('should require a target user', function()
env.db.set_next_result({ { id = 'fed-1', name = 'Fed', owner_id = message.from.id } })
message.args = nil
message.reply = nil
fban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'specify a user')
end)
it('should not ban the federation owner', function()
message.from.id = 111111
env.db.set_next_result({ { id = 'fed-1', name = 'Fed', owner_id = 111111 } })
message.reply = { from = { id = 111111, first_name = 'Owner' }, message_id = 1 }
fban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'cannot federation%-ban the federation owner')
end)
it('should not ban allowlisted users', function()
message.from.id = 111111
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 111111 } })
env.db.queue_result({ { ['1'] = 1 } }) -- is allowlisted
message.reply = { from = { id = 222222, first_name = 'Target' }, message_id = 1 }
fban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'allowlist')
end)
it('should ban user across federation chats', function()
message.from.id = 111111
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 111111 } }) -- get fed
env.db.queue_result({}) -- not allowlisted
env.db.queue_result({}) -- not already banned
env.db.queue_result({}) -- insert ban
env.db.queue_result({ { chat_id = -100111 }, { chat_id = -100222 } }) -- fed chats
message.reply = { from = { id = 222222, first_name = 'Target' }, message_id = 1 }
fban.on_message(env.api, message, ctx)
assert.are.equal(2, env.api.count_calls('ban_chat_member'))
test_helper.assert_sent_message_matches(env.api, 'Federation Ban')
end)
it('should invalidate Redis cache after fban', function()
message.from.id = 111111
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 111111 } })
env.db.queue_result({}) -- not allowlisted
env.db.queue_result({}) -- not already banned
env.db.queue_result({}) -- insert
env.db.queue_result({}) -- chats
message.reply = { from = { id = 222222, first_name = 'Target' }, message_id = 1 }
fban.on_message(env.api, message, ctx)
test_helper.assert_redis_command(env.redis, 'del')
end)
it('should include reason in ban record', function()
message.from.id = 111111
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 111111 } })
env.db.queue_result({}) -- not allowlisted
env.db.queue_result({}) -- not already banned
env.db.queue_result({}) -- insert
env.db.queue_result({}) -- chats
message.reply = { from = { id = 222222, first_name = 'Target' }, message_id = 1 }
message.args = 'spamming links'
fban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'spamming links')
end)
it('should resolve user from username', function()
message.from.id = 111111
env.redis.set('username:targetuser', '333333')
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 111111 } })
env.db.queue_result({}) -- not allowlisted
env.db.queue_result({}) -- not already banned
env.db.queue_result({}) -- insert
env.db.queue_result({}) -- chats
message.args = '@targetuser reason'
message.reply = nil
fban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'Federation Ban')
end)
end)
describe('unfban', function()
local unfban
before_each(function()
package.loaded['src.plugins.admin.federation.unfban'] = nil
unfban = require('src.plugins.admin.federation.unfban')
end)
it('should have correct metadata', function()
assert.are.equal('unfban', unfban.name)
assert.is_true(unfban.group_only)
end)
it('should require federation membership', function()
env.db.set_next_result({})
unfban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'not part of any federation')
end)
it('should require federation admin permission', function()
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 999 } })
env.db.queue_result({}) -- not a fed admin
message.from.id = 111111
unfban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'federation owner or a federation admin')
end)
it('should require a target user', function()
env.db.set_next_result({ { id = 'fed-1', name = 'Fed', owner_id = message.from.id } })
message.args = nil
message.reply = nil
unfban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'specify a user')
end)
it('should report when user is not banned', function()
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = message.from.id } })
env.db.queue_result({}) -- not banned
message.args = '222222'
unfban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'not banned')
end)
it('should unban user across federation chats', function()
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = message.from.id } })
env.db.queue_result({ { ['1'] = 1 } }) -- is banned
env.db.queue_result({}) -- delete ban
env.db.queue_result({ { chat_id = -100111 }, { chat_id = -100222 } }) -- fed chats
message.args = '222222'
unfban.on_message(env.api, message, ctx)
assert.are.equal(2, env.api.count_calls('unban_chat_member'))
test_helper.assert_sent_message_matches(env.api, 'Federation Unban')
end)
it('should invalidate Redis cache after unfban', function()
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = message.from.id } })
env.db.queue_result({ { ['1'] = 1 } })
env.db.queue_result({})
env.db.queue_result({})
message.args = '222222'
unfban.on_message(env.api, message, ctx)
test_helper.assert_redis_command(env.redis, 'del')
end)
end)
describe('fbaninfo', function()
local fbaninfo
before_each(function()
package.loaded['src.plugins.admin.federation.fbaninfo'] = nil
fbaninfo = require('src.plugins.admin.federation.fbaninfo')
end)
it('should have correct metadata', function()
assert.are.equal('fbaninfo', fbaninfo.name)
assert.are.same({ 'fbaninfo' }, fbaninfo.commands)
end)
it('should default to sender when no user specified', function()
message.args = nil
message.reply = nil
ctx.is_group = true
env.db.set_next_result({}) -- no bans found
fbaninfo.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'not banned')
end)
it('should show ban info for banned user (group context)', function()
ctx.is_group = true
env.db.set_next_result({
{
reason = 'Spamming',
banned_by = 111111,
banned_at = '2024-01-01',
name = 'Test Fed',
id = 'fed-uuid',
}
})
message.args = '222222'
fbaninfo.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'Federation Ban Info')
test_helper.assert_sent_message_matches(env.api, 'Spamming')
end)
it('should show no-ban message for clean user', function()
ctx.is_group = true
env.db.set_next_result({})
message.args = '222222'
fbaninfo.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'not banned')
end)
it('should resolve user from reply', function()
ctx.is_group = true
message.reply = { from = { id = 222222, first_name = 'Target' }, message_id = 1 }
message.args = nil
env.db.set_next_result({})
fbaninfo.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'not banned')
end)
it('should query all federations in private chat', function()
ctx.is_group = false
ctx.is_private = true
message.chat.type = 'private'
env.db.set_next_result({})
message.args = '222222'
fbaninfo.on_message(env.api, message, ctx)
- -- Verify it queried without chat_id constraint
+ -- Verify it used the "all" stored procedure (not group-scoped)
local found_private_query = false
for _, q in ipairs(env.db.queries) do
- if q.sql and q.sql:match('federation_bans') and not q.sql:match('fc%.chat_id') then
+ if q.op == 'call' and q.func_name == 'sp_get_fban_info_all' then
found_private_query = true
end
end
assert.is_true(found_private_query)
end)
it('should show multiple bans', function()
ctx.is_group = false
ctx.is_private = true
message.chat.type = 'private'
env.db.set_next_result({
{ reason = 'Reason 1', name = 'Fed A', id = 'fed-1', banned_by = 111, banned_at = '2024-01-01' },
{ reason = 'Reason 2', name = 'Fed B', id = 'fed-2', banned_by = 222, banned_at = '2024-02-01' },
})
message.args = '222222'
fbaninfo.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'Fed A')
test_helper.assert_sent_message_matches(env.api, 'Fed B')
end)
end)
end)
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Mon, May 11, 1:20 AM (5 d, 16 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
62954
Default Alt Text
(63 KB)
Attached To
Mode
R69 mattata
Attached
Detach File
Event Timeline