Page MenuHomePhabricator (Chris)

No OneTemporary

Authored By
Unknown
Size
63 KB
Referenced Files
None
Subscribers
None
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('&', '&amp;'):gsub('<', '&lt;'):gsub('>', '&gt;')
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('&', '&amp;'):gsub('<', '&lt;'):gsub('>', '&gt;')
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

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)

Event Timeline