telegram bot logs, first pass

This commit is contained in:
Dylan Knutson
2025-08-05 05:05:21 +00:00
parent baed10db21
commit 24a59d50f2
24 changed files with 4475 additions and 14 deletions

View File

@@ -0,0 +1,108 @@
# typed: false
class TelegramBotLogsController < ApplicationController
before_action :set_telegram_bot_log, only: %i[show]
after_action :verify_authorized
def index
authorize TelegramBotLog
# Start with policy scope
@telegram_bot_logs = policy_scope(TelegramBotLog)
# Apply filters
@telegram_bot_logs = apply_filters(@telegram_bot_logs)
# Order by most recent first
@telegram_bot_logs = @telegram_bot_logs.recent
# Paginate with Kaminari
@limit = (params[:limit] || 50).to_i.clamp(1, 500)
@telegram_bot_logs = @telegram_bot_logs.page(params[:page]).per(@limit)
# Load associations for display
@telegram_bot_logs = @telegram_bot_logs.includes(:processed_image)
# Set up filter options for the view
@status_options = TelegramBotLog.statuses.keys
@filter_params =
params.slice(
:telegram_user_id,
:status,
:start_date,
:end_date,
:min_results,
:max_results,
:slow_requests,
)
end
def show
authorize @telegram_bot_log
# The processed_image association will be loaded automatically when accessed
end
private
def set_telegram_bot_log
@telegram_bot_log =
TelegramBotLog.includes(:processed_image).find(params[:id])
rescue ActiveRecord::RecordNotFound
redirect_to telegram_bot_logs_path, alert: "Telegram bot log not found."
end
def apply_filters(scope)
# Filter by telegram user ID
if params[:telegram_user_id].present?
scope = scope.for_user(params[:telegram_user_id].to_i)
end
# Filter by status
if params[:status].present? && TelegramBotLog.statuses.key?(params[:status])
scope = scope.where(status: params[:status])
end
# Filter by date range
if params[:start_date].present?
begin
start_date = Date.parse(params[:start_date])
scope =
scope.where("request_timestamp >= ?", start_date.beginning_of_day)
rescue Date::Error
# Ignore invalid date
end
end
if params[:end_date].present?
begin
end_date = Date.parse(params[:end_date])
scope = scope.where("request_timestamp <= ?", end_date.end_of_day)
rescue Date::Error
# Ignore invalid date
end
end
# Filter by search results count
if params[:min_results].present?
scope =
scope.where("search_results_count >= ?", params[:min_results].to_i)
end
if params[:max_results].present?
scope =
scope.where("search_results_count <= ?", params[:max_results].to_i)
end
# Filter by performance metrics
if params[:slow_requests].present? && params[:slow_requests] == "true"
# Consider requests with total processing time > 1 second as slow
scope =
scope.where(
"(fingerprint_computation_time + search_computation_time) > ? OR fingerprint_computation_time IS NULL OR search_computation_time IS NULL",
1.0,
)
end
scope
end
end

35
app/lib/stopwatch.rb Normal file
View File

@@ -0,0 +1,35 @@
# typed: strict
# A simple stopwatch utility for measuring elapsed time
class Stopwatch
extend T::Sig
sig { params(start_time: T.any(Time, ActiveSupport::TimeWithZone)).void }
def initialize(start_time)
@start_time = T.let(start_time, T.any(Time, ActiveSupport::TimeWithZone))
end
sig { returns(Stopwatch) }
def self.start
new(Time.current)
end
sig { returns(Float) }
def elapsed
Time.current - @start_time
end
sig { returns(String) }
def elapsed_ms
"#{(elapsed * 1000).round(1)}ms"
end
sig { returns(String) }
def elapsed_s
"#{sprintf("%.3f", elapsed)}s"
end
sig { returns(T.any(Time, ActiveSupport::TimeWithZone)) }
def start_time
@start_time
end
end

View File

@@ -4,6 +4,7 @@ require "telegram/bot"
require "tempfile"
require "net/http"
require "uri"
require_relative "../stopwatch"
module Tasks
class TelegramBotTask
@@ -76,8 +77,14 @@ module Tasks
def handle_message(bot, message)
return unless message.photo || message.document
# Start timing the total request
total_request_timer = Stopwatch.start
chat_id = message.chat.id
# Create initial log record
telegram_log = create_telegram_log(message)
# Send initial response
response_message =
bot.api.send_message(
@@ -88,13 +95,34 @@ module Tasks
begin
# Process the image and perform visual search
search_result = process_image_message(bot, message)
search_result, processed_blob =
process_image_message_with_logging(bot, message, telegram_log)
if search_result
if search_result && !search_result.empty?
result_text = format_search_results(search_result)
# Update log with success
update_telegram_log_success(
telegram_log,
search_result,
result_text,
processed_blob,
)
elsif search_result
result_text = "❌ No close matches found."
# Update log with no results
update_telegram_log_no_results(
telegram_log,
result_text,
processed_blob,
)
else
result_text =
"❌ Could not process the image. Please make sure it's a valid image file."
# Update log with invalid image
update_telegram_log_invalid_image(telegram_log, result_text)
end
# Update the response with results
@@ -104,9 +132,17 @@ module Tasks
text: result_text,
parse_mode: "Markdown",
)
# Record total request time
total_request_time = total_request_timer.elapsed
telegram_log.update!(total_request_time: total_request_time)
log("⏱️ Total request completed in #{total_request_timer.elapsed_s}")
rescue StandardError => e
log("Error processing image: #{e.message}")
# Update log with error
update_telegram_log_error(telegram_log, e)
# Update with error message
bot.api.edit_message_text(
chat_id: chat_id,
@@ -114,6 +150,13 @@ module Tasks
text:
"❌ An error occurred while processing your image. Please try again.",
)
# Record total request time even for errors
total_request_time = total_request_timer.elapsed
telegram_log.update!(total_request_time: total_request_time)
log(
"⏱️ Total request (with error) completed in #{total_request_timer.elapsed_s}",
)
end
end
@@ -121,34 +164,76 @@ module Tasks
params(
bot: Telegram::Bot::Client,
message: Telegram::Bot::Types::Message,
telegram_log: TelegramBotLog,
).returns(
T.nilable(
T::Array[Domain::VisualSearchHelper::SimilarFingerprintResult],
),
[
T.nilable(
T::Array[Domain::VisualSearchHelper::SimilarFingerprintResult],
),
T.nilable(BlobFile),
],
)
end
def process_image_message(bot, message)
def process_image_message_with_logging(bot, message, telegram_log)
log("📥 Received image message from chat #{message.chat.id}")
# Get the largest photo or document
image_file = get_image_file_from_message(message)
return nil unless image_file
return nil, nil unless image_file
# Download the image to a temporary file
download_stopwatch = Stopwatch.start
temp_file = download_telegram_image(bot, image_file)
return nil unless temp_file
download_time = download_stopwatch.elapsed
return nil, nil unless temp_file
log("📥 Downloaded image in #{download_stopwatch.elapsed_s}")
processed_blob = nil
begin
# Generate fingerprints
# Time image processing (file reading and BlobFile creation)
image_processing_stopwatch = Stopwatch.start
file_path = T.must(temp_file.path)
file_content = File.binread(file_path)
# Create BlobFile for the processed image
content_type =
case image_file
when Telegram::Bot::Types::Document
image_file.mime_type || "application/octet-stream"
when Telegram::Bot::Types::PhotoSize
"image/jpeg" # Telegram photos are typically JPEG
else
"application/octet-stream"
end
processed_blob =
BlobFile.find_or_initialize_from_contents(file_content) do |blob|
blob.content_type = content_type
end
processed_blob.save! unless processed_blob.persisted?
image_processing_time = image_processing_stopwatch.elapsed
log("🔧 Processed image in #{image_processing_stopwatch.elapsed_s}")
# Time fingerprint generation
fingerprint_stopwatch = Stopwatch.start
fingerprint_value =
Domain::PostFile::BitFingerprint.from_file_path(file_path)
detail_fingerprint_value =
Domain::PostFile::BitFingerprint.detail_from_file_path(file_path)
fingerprint_computation_time = fingerprint_stopwatch.elapsed
log("🔍 Generated fingerprints, searching for similar images...")
log(
"🔍 Generated fingerprints in #{fingerprint_stopwatch.elapsed_s}, searching for similar images...",
)
# Find similar fingerprints using the existing helper
# Time search operation
search_stopwatch = Stopwatch.start
similar_results =
find_similar_fingerprints(
fingerprint_value: fingerprint_value,
@@ -159,24 +244,144 @@ module Tasks
post_file: :post,
},
)
search_computation_time = search_stopwatch.elapsed
# Update timing metrics in log
telegram_log.update!(
download_time: download_time,
image_processing_time: image_processing_time,
fingerprint_computation_time: fingerprint_computation_time,
search_computation_time: search_computation_time,
)
# Filter to only >90% similarity
high_quality_matches =
similar_results.select { |result| result.similarity_percentage > 90 }
log(
"✅ Found #{high_quality_matches.length} high-quality matches (>90% similarity)",
"✅ Found #{high_quality_matches.length} high-quality matches (>90% similarity) in #{search_stopwatch.elapsed_s}",
)
high_quality_matches
[high_quality_matches, processed_blob]
rescue StandardError => e
log("❌ Error processing image: #{e.message}")
nil
[nil, processed_blob]
ensure
# Clean up temp file
temp_file.unlink if temp_file
end
end
# Logging helper methods
sig do
params(message: Telegram::Bot::Types::Message).returns(TelegramBotLog)
end
def create_telegram_log(message)
user = message.from
chat = message.chat
TelegramBotLog.create!(
telegram_user_id: user&.id || 0,
telegram_username: user&.username,
telegram_first_name: user&.first_name,
telegram_last_name: user&.last_name,
telegram_chat_id: chat.id,
request_timestamp: Time.current,
status: :success, # Will be updated later
search_results_count: 0,
response_data: {
},
)
end
sig do
params(
telegram_log: TelegramBotLog,
search_results:
T::Array[Domain::VisualSearchHelper::SimilarFingerprintResult],
response_text: String,
processed_blob: T.nilable(BlobFile),
).void
end
def update_telegram_log_success(
telegram_log,
search_results,
response_text,
processed_blob
)
telegram_log.update!(
status: :success,
search_results_count: search_results.length,
processed_image: processed_blob,
response_data: {
response_text: response_text,
matches: search_results.length,
threshold: 90,
results:
search_results
.take(5)
.map do |result|
post = result.fingerprint.post_file&.post
{
similarity: result.similarity_percentage.round(1),
post_id: post&.id,
url: post&.external_url_for_view,
}
end,
},
)
end
sig do
params(
telegram_log: TelegramBotLog,
response_text: String,
processed_blob: T.nilable(BlobFile),
).void
end
def update_telegram_log_no_results(
telegram_log,
response_text,
processed_blob
)
telegram_log.update!(
status: :no_results,
search_results_count: 0,
processed_image: processed_blob,
response_data: {
response_text: response_text,
matches: 0,
threshold: 90,
},
)
end
sig { params(telegram_log: TelegramBotLog, response_text: String).void }
def update_telegram_log_invalid_image(telegram_log, response_text)
telegram_log.update!(
status: :invalid_image,
search_results_count: 0,
error_message: "Invalid or unsupported image format",
response_data: {
response_text: response_text,
error: "Invalid image format",
},
)
end
sig { params(telegram_log: TelegramBotLog, error: StandardError).void }
def update_telegram_log_error(telegram_log, error)
telegram_log.update!(
status: :error,
search_results_count: 0,
error_message: error.message,
response_data: {
error: error.message,
error_class: error.class.name,
},
)
end
sig do
params(
results: T::Array[Domain::VisualSearchHelper::SimilarFingerprintResult],

View File

@@ -0,0 +1,91 @@
# typed: strict
class TelegramBotLog < ReduxApplicationRecord
self.table_name = "telegram_bot_logs"
# Association to processed image blob
belongs_to :processed_image,
foreign_key: :processed_image_sha256,
class_name: "::BlobFile",
optional: true
# Status enum for tracking request outcomes
enum :status,
{
success: "success",
error: "error",
no_results: "no_results",
invalid_image: "invalid_image",
},
prefix: true
# Validations
validates :telegram_user_id, presence: true
validates :telegram_chat_id, presence: true
validates :request_timestamp, presence: true
validates :status, presence: true
validates :search_results_count,
presence: true,
numericality: {
greater_than_or_equal_to: 0,
}
validates :download_time, numericality: { greater_than: 0 }, allow_nil: true
validates :image_processing_time,
numericality: {
greater_than: 0,
},
allow_nil: true
validates :fingerprint_computation_time,
numericality: {
greater_than: 0,
},
allow_nil: true
validates :search_computation_time,
numericality: {
greater_than: 0,
},
allow_nil: true
validates :total_request_time,
numericality: {
greater_than: 0,
},
allow_nil: true
validates :processed_image_sha256, length: { is: 32 }, allow_nil: true
# Scopes for common queries
scope :for_user,
->(telegram_user_id) { where(telegram_user_id: telegram_user_id) }
scope :successful, -> { where(status: :success) }
scope :with_results, -> { where("search_results_count > 0") }
scope :recent, -> { order(request_timestamp: :desc) }
scope :by_date_range,
->(start_date, end_date) do
where(request_timestamp: start_date..end_date)
end
# Helper methods
sig { returns(String) }
def user_display_name
if telegram_first_name.present? && telegram_last_name.present?
"#{telegram_first_name} #{telegram_last_name}"
elsif telegram_first_name.present?
T.must(telegram_first_name)
elsif telegram_username.present?
"@#{telegram_username}"
else
"User #{telegram_user_id}"
end
end
sig { returns(T::Boolean) }
def has_performance_metrics?
download_time.present? && fingerprint_computation_time.present? &&
search_computation_time.present? && total_request_time.present?
end
sig { returns(T::Boolean) }
def has_complete_performance_metrics?
download_time.present? && image_processing_time.present? &&
fingerprint_computation_time.present? &&
search_computation_time.present? && total_request_time.present?
end
end

View File

@@ -0,0 +1,27 @@
# typed: strict
class TelegramBotLogPolicy < ApplicationPolicy
extend T::Sig
sig { returns(T::Boolean) }
def index?
is_real_user? && is_role_admin?
end
sig { returns(T::Boolean) }
def show?
is_real_user? && is_role_admin?
end
class Scope < ApplicationPolicy::Scope
extend T::Sig
sig { returns(T.untyped) }
def resolve
if @user&.admin?
scope.all
else
scope.where(id: nil) # Returns empty relation for non-admin users
end
end
end
end

View File

@@ -117,6 +117,10 @@
<i class="fas fa-list mr-3 w-4 text-slate-400"></i>
<span>Log Entries</span>
<% end %>
<%= link_to telegram_bot_logs_path, class: "flex w-full items-center px-4 py-2 text-sm text-slate-700 transition-colors hover:bg-slate-50 hover:text-slate-900" do %>
<i class="fas fa-robot mr-3 w-4 text-slate-400"></i>
<span>Telegram Bot Logs</span>
<% end %>
<div class="my-2 border-t border-slate-200"></div>
</div>
<% end %>

View File

@@ -0,0 +1,215 @@
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-slate-900">Telegram Bot Audit Logs</h1>
<p class="mt-2 text-sm text-slate-700">
Comprehensive audit trail of all Telegram bot interactions and performance metrics.
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<div class="text-sm text-slate-600">
Showing <%= @telegram_bot_logs.count %> of <%= @telegram_bot_logs.total_count %> logs
</div>
</div>
</div>
<!-- Filter Form -->
<div class="mt-6 bg-white shadow sm:rounded-lg">
<div class="px-4 py-4 border-b border-slate-200">
<h3 class="text-lg font-medium text-slate-900">Filters</h3>
<%= form_with url: telegram_bot_logs_path, method: :get, local: true, class: "mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4" do |f| %>
<!-- User ID Filter -->
<div>
<%= f.label :telegram_user_id, "Telegram User ID", class: "block text-sm font-medium text-slate-700" %>
<%= f.number_field :telegram_user_id,
value: @filter_params[:telegram_user_id],
class: "mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
placeholder: "Enter user ID" %>
</div>
<!-- Status Filter -->
<div>
<%= f.label :status, "Status", class: "block text-sm font-medium text-slate-700" %>
<%= f.select :status,
options_for_select([["All Statuses", ""]] + @status_options.map { |s| [s.humanize, s] }, @filter_params[:status]),
{},
{ class: "mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
</div>
<!-- Date Range Filters -->
<div>
<%= f.label :start_date, "Start Date", class: "block text-sm font-medium text-slate-700" %>
<%= f.date_field :start_date,
value: @filter_params[:start_date],
class: "mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
<div>
<%= f.label :end_date, "End Date", class: "block text-sm font-medium text-slate-700" %>
<%= f.date_field :end_date,
value: @filter_params[:end_date],
class: "mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
<!-- Results Count Filters -->
<div>
<%= f.label :min_results, "Min Results", class: "block text-sm font-medium text-slate-700" %>
<%= f.number_field :min_results,
value: @filter_params[:min_results],
min: 0,
class: "mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
placeholder: "Min results" %>
</div>
<div>
<%= f.label :max_results, "Max Results", class: "block text-sm font-medium text-slate-700" %>
<%= f.number_field :max_results,
value: @filter_params[:max_results],
min: 0,
class: "mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
placeholder: "Max results" %>
</div>
<!-- Performance Filter -->
<div class="flex items-center">
<%= f.check_box :slow_requests,
{ checked: @filter_params[:slow_requests] == "true", class: "h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500" },
"true", "" %>
<%= f.label :slow_requests, "Show slow requests only", class: "ml-2 block text-sm text-slate-700" %>
</div>
<!-- Action Buttons -->
<div class="flex items-end gap-2">
<%= f.submit "Apply Filters", class: "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded text-sm" %>
<%= link_to "Clear", telegram_bot_logs_path, class: "bg-slate-500 hover:bg-slate-700 text-white font-bold py-2 px-4 rounded text-sm" %>
</div>
<% end %>
</div>
</div>
<!-- Logs Table -->
<% if @telegram_bot_logs.any? %>
<div class="mt-6 overflow-hidden bg-white shadow sm:rounded-lg">
<div class="p-3 sm:p-4">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-300">
<thead>
<tr>
<th class="pb-2 text-left text-sm font-semibold text-slate-900">User</th>
<th class="pb-2 text-left text-sm font-semibold text-slate-900">Status</th>
<th class="pb-2 text-left text-sm font-semibold text-slate-900">Results</th>
<th class="pb-2 text-left text-sm font-semibold text-slate-900">Performance</th>
<th class="pb-2 text-left text-sm font-semibold text-slate-900">Timestamp</th>
<th class="relative pb-2"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200">
<% @telegram_bot_logs.each do |log| %>
<tr class="hover:bg-slate-50">
<td class="py-3 pr-4 text-sm">
<div class="font-medium text-slate-900 truncate max-w-32">
<%= log.user_display_name %>
</div>
<div class="text-slate-500 text-xs">
ID: <%= log.telegram_user_id %>
</div>
</td>
<td class="py-3 pr-4 text-sm">
<% status_color = case log.status
when "success"
"bg-green-100 text-green-800"
when "error"
"bg-red-100 text-red-800"
when "no_results"
"bg-yellow-100 text-yellow-800"
when "invalid_image"
"bg-orange-100 text-orange-800"
else
"bg-slate-100 text-slate-800"
end %>
<span class="<%= status_color %> inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium">
<%= log.status.humanize %>
</span>
</td>
<td class="py-3 pr-4 text-sm text-slate-500">
<div class="font-medium text-slate-900">
<%= log.search_results_count %>
</div>
<% if log.search_results_count > 0 %>
<div class="text-xs text-green-600">
matches found
</div>
<% else %>
<div class="text-xs text-slate-400">
no matches
</div>
<% end %>
</td>
<td class="py-3 pr-4 text-sm text-slate-500">
<% if log.has_performance_metrics? %>
<div class="text-xs space-y-1">
<div class="flex justify-between">
<span class="text-blue-600">Download:</span>
<span class="font-mono"><%= sprintf("%.3f", log.download_time) %>s</span>
</div>
<% if log.image_processing_time.present? %>
<div class="flex justify-between">
<span class="text-purple-600">Process:</span>
<span class="font-mono"><%= sprintf("%.3f", log.image_processing_time) %>s</span>
</div>
<% end %>
<div class="flex justify-between">
<span class="text-yellow-600">Fingerprint:</span>
<span class="font-mono"><%= sprintf("%.3f", log.fingerprint_computation_time) %>s</span>
</div>
<div class="flex justify-between">
<span class="text-green-600">Search:</span>
<span class="font-mono"><%= sprintf("%.3f", log.search_computation_time) %>s</span>
</div>
<div class="flex justify-between font-medium border-t pt-1">
<span>Total:</span>
<span class="font-mono
<%= log.total_request_time && log.total_request_time > 1.0 ? 'text-red-600' : 'text-slate-700' %>">
<%= log.total_request_time ? sprintf("%.3f", log.total_request_time) : 'N/A' %>s
</span>
</div>
</div>
<% else %>
<span class="text-slate-400 text-xs">No metrics</span>
<% end %>
</td>
<td class="py-3 pr-4 text-sm text-slate-500">
<div class="text-xs">
<%= log.request_timestamp.strftime("%m/%d/%Y") %>
</div>
<div class="text-xs font-mono">
<%= log.request_timestamp.strftime("%H:%M:%S") %>
</div>
<div class="text-xs text-slate-400">
<%= time_ago_in_words(log.request_timestamp) %> ago
</div>
</td>
<td class="py-3 text-right text-sm font-medium">
<%= link_to "View", telegram_bot_log_path(log),
class: "text-blue-600 hover:text-blue-800 font-medium" %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
<!-- Pagination -->
<% if @telegram_bot_logs.respond_to?(:current_page) %>
<%= render "shared/pagination_controls", collection: @telegram_bot_logs %>
<% end %>
<% else %>
<div class="mt-6 bg-white shadow sm:rounded-lg">
<div class="px-4 py-6 text-center">
<div class="mx-auto h-12 w-12 text-slate-400">
<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-12 h-12">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3-3v-6a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6a2.25 2.25 0 002.25 2.25h10.5A2.25 2.25 0 0019.5 15z" />
</svg>
</div>
<h3 class="mt-2 text-sm font-semibold text-slate-900">No logs found</h3>
<p class="mt-1 text-sm text-slate-500">No Telegram bot logs match your current filters.</p>
<div class="mt-6">
<%= link_to "Clear filters", telegram_bot_logs_path,
class: "inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
</div>
</div>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,276 @@
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-slate-900">Telegram Bot Log Details</h1>
<p class="mt-2 text-sm text-slate-700">
Detailed view of Telegram bot interaction #<%= @telegram_bot_log.id %>
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<%= link_to "← Back to Logs", telegram_bot_logs_path,
class: "bg-slate-500 hover:bg-slate-700 text-white font-bold py-2 px-4 rounded" %>
</div>
</div>
<div class="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- User Information -->
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-slate-900">User Information</h3>
<div class="mt-4 space-y-3">
<div class="flex justify-between">
<dt class="text-sm font-medium text-slate-500">Display Name</dt>
<dd class="text-sm text-slate-900 font-medium"><%= @telegram_bot_log.user_display_name %></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-slate-500">Telegram User ID</dt>
<dd class="text-sm text-slate-900 font-mono"><%= @telegram_bot_log.telegram_user_id %></dd>
</div>
<% if @telegram_bot_log.telegram_username.present? %>
<div class="flex justify-between">
<dt class="text-sm font-medium text-slate-500">Username</dt>
<dd class="text-sm text-slate-900">@<%= @telegram_bot_log.telegram_username %></dd>
</div>
<% end %>
<% if @telegram_bot_log.telegram_first_name.present? %>
<div class="flex justify-between">
<dt class="text-sm font-medium text-slate-500">First Name</dt>
<dd class="text-sm text-slate-900"><%= @telegram_bot_log.telegram_first_name %></dd>
</div>
<% end %>
<% if @telegram_bot_log.telegram_last_name.present? %>
<div class="flex justify-between">
<dt class="text-sm font-medium text-slate-500">Last Name</dt>
<dd class="text-sm text-slate-900"><%= @telegram_bot_log.telegram_last_name %></dd>
</div>
<% end %>
<div class="flex justify-between">
<dt class="text-sm font-medium text-slate-500">Chat ID</dt>
<dd class="text-sm text-slate-900 font-mono"><%= @telegram_bot_log.telegram_chat_id %></dd>
</div>
</div>
</div>
</div>
<!-- Request Information -->
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-slate-900">Request Information</h3>
<div class="mt-4 space-y-3">
<div class="flex justify-between">
<dt class="text-sm font-medium text-slate-500">Status</dt>
<dd class="text-sm">
<% status_color = case @telegram_bot_log.status
when "success"
"bg-green-100 text-green-800"
when "error"
"bg-red-100 text-red-800"
when "no_results"
"bg-yellow-100 text-yellow-800"
when "invalid_image"
"bg-orange-100 text-orange-800"
else
"bg-slate-100 text-slate-800"
end %>
<span class="<%= status_color %> inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium">
<%= @telegram_bot_log.status.humanize %>
</span>
</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-slate-500">Timestamp</dt>
<dd class="text-sm text-slate-900">
<div><%= @telegram_bot_log.request_timestamp.strftime("%B %d, %Y at %I:%M:%S %p") %></div>
<div class="text-xs text-slate-500"><%= time_ago_in_words(@telegram_bot_log.request_timestamp) %> ago</div>
</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-slate-500">Search Results</dt>
<dd class="text-sm text-slate-900">
<span class="font-medium"><%= @telegram_bot_log.search_results_count %></span>
<% if @telegram_bot_log.search_results_count > 0 %>
<span class="text-green-600 text-xs ml-1">matches found</span>
<% else %>
<span class="text-slate-400 text-xs ml-1">no matches</span>
<% end %>
</dd>
</div>
<% if @telegram_bot_log.error_message.present? %>
<div class="space-y-1">
<dt class="text-sm font-medium text-slate-500">Error Message</dt>
<dd class="text-sm text-red-700 bg-red-50 p-2 rounded border">
<%= @telegram_bot_log.error_message %>
</dd>
</div>
<% end %>
</div>
</div>
</div>
</div>
<!-- Performance Metrics -->
<% if @telegram_bot_log.has_performance_metrics? %>
<div class="mt-6 bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-slate-900">Performance Metrics</h3>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<div class="bg-blue-50 p-4 rounded-lg">
<div class="text-sm font-medium text-slate-500">Download Time</div>
<div class="text-xl font-bold text-blue-700 font-mono">
<%= sprintf("%.3f", @telegram_bot_log.download_time) %>s
</div>
<div class="text-xs text-blue-600">From Telegram</div>
</div>
<% if @telegram_bot_log.image_processing_time.present? %>
<div class="bg-purple-50 p-4 rounded-lg">
<div class="text-sm font-medium text-slate-500">Processing Time</div>
<div class="text-xl font-bold text-purple-700 font-mono">
<%= sprintf("%.3f", @telegram_bot_log.image_processing_time) %>s
</div>
<div class="text-xs text-purple-600">File handling</div>
</div>
<% end %>
<div class="bg-yellow-50 p-4 rounded-lg">
<div class="text-sm font-medium text-slate-500">Fingerprint Time</div>
<div class="text-xl font-bold text-yellow-700 font-mono">
<%= sprintf("%.3f", @telegram_bot_log.fingerprint_computation_time) %>s
</div>
<div class="text-xs text-yellow-600">Image analysis</div>
</div>
<div class="bg-green-50 p-4 rounded-lg">
<div class="text-sm font-medium text-slate-500">Search Time</div>
<div class="text-xl font-bold text-green-700 font-mono">
<%= sprintf("%.3f", @telegram_bot_log.search_computation_time) %>s
</div>
<div class="text-xs text-green-600">Find matches</div>
</div>
<div class="bg-slate-50 p-4 rounded-lg">
<div class="text-sm font-medium text-slate-500">Total Request Time</div>
<div class="text-xl font-bold font-mono
<%= @telegram_bot_log.total_request_time && @telegram_bot_log.total_request_time > 1.0 ? 'text-red-600' : 'text-slate-700' %>">
<%= @telegram_bot_log.total_request_time ? sprintf("%.3f", @telegram_bot_log.total_request_time) : 'N/A' %>s
</div>
<div class="text-xs
<%= @telegram_bot_log.total_request_time && @telegram_bot_log.total_request_time > 1.0 ? 'text-red-500' : 'text-slate-500' %>">
<%= @telegram_bot_log.total_request_time && @telegram_bot_log.total_request_time > 1.0 ? 'Slow request' : 'Normal speed' %>
</div>
</div>
</div>
</div>
</div>
<% end %>
<!-- Processed Image -->
<% if @telegram_bot_log.processed_image.present? %>
<div class="mt-6 bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-slate-900">Processed Image</h3>
<div class="mt-4 space-y-3">
<div class="flex justify-between">
<dt class="text-sm font-medium text-slate-500">Content Type</dt>
<dd class="text-sm text-slate-900 font-mono"><%= @telegram_bot_log.processed_image.content_type %></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-slate-500">File Size</dt>
<dd class="text-sm text-slate-900"><%= number_to_human_size(@telegram_bot_log.processed_image.size_bytes) %></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-slate-500">SHA256 Hash</dt>
<dd class="text-sm text-slate-900 font-mono break-all"><%= @telegram_bot_log.processed_image.sha256.unpack1('H*') %></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-slate-500">Created</dt>
<dd class="text-sm text-slate-900">
<%= @telegram_bot_log.processed_image.created_at.strftime("%B %d, %Y at %I:%M:%S %p") %>
<div class="text-xs text-slate-500"><%= time_ago_in_words(@telegram_bot_log.processed_image.created_at) %> ago</div>
</dd>
</div>
</div>
</div>
</div>
<% end %>
<!-- Response Data -->
<% if @telegram_bot_log.response_data.present? && @telegram_bot_log.response_data.any? %>
<div class="mt-6 bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-slate-900">Response Data</h3>
<div class="mt-4">
<% if @telegram_bot_log.status == "success" && @telegram_bot_log.response_data["results"].present? %>
<!-- Success Response with Results -->
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="flex justify-between">
<span class="font-medium text-slate-500">Matches Found:</span>
<span class="text-slate-900"><%= @telegram_bot_log.response_data["matches"] %></span>
</div>
<div class="flex justify-between">
<span class="font-medium text-slate-500">Similarity Threshold:</span>
<span class="text-slate-900"><%= @telegram_bot_log.response_data["threshold"] %>%</span>
</div>
</div>
<div class="border-t border-slate-200 pt-4">
<h4 class="font-medium text-slate-900 mb-3">Search Results</h4>
<div class="space-y-2">
<% @telegram_bot_log.response_data["results"].each_with_index do |result, index| %>
<div class="bg-slate-50 p-3 rounded border">
<div class="flex justify-between items-start">
<div>
<div class="text-sm font-medium text-slate-900">
Result #<%= index + 1 %>
</div>
<% if result["post_id"] %>
<div class="text-xs text-slate-500">Post ID: <%= result["post_id"] %></div>
<% end %>
</div>
<div class="text-right">
<div class="text-sm font-bold
<%= result["similarity"] >= 95 ? 'text-green-600' : result["similarity"] >= 90 ? 'text-yellow-600' : 'text-red-600' %>">
<%= result["similarity"] %>% similar
</div>
</div>
</div>
<% if result["url"] %>
<div class="mt-2">
<%= link_to result["url"], result["url"],
target: "_blank",
class: "text-blue-600 hover:text-blue-800 text-xs break-all" %>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
<% else %>
<!-- Raw JSON Response -->
<div class="bg-slate-50 p-4 rounded-lg border">
<pre class="text-sm text-slate-900 whitespace-pre-wrap break-words"><%= JSON.pretty_generate(@telegram_bot_log.response_data) %></pre>
</div>
<% end %>
</div>
</div>
</div>
<% end %>
<!-- System Information -->
<div class="mt-6 bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-slate-900">System Information</h3>
<div class="mt-4 space-y-3">
<div class="flex justify-between">
<dt class="text-sm font-medium text-slate-500">Record ID</dt>
<dd class="text-sm text-slate-900 font-mono"><%= @telegram_bot_log.id %></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-slate-500">Created At</dt>
<dd class="text-sm text-slate-900">
<%= @telegram_bot_log.created_at.strftime("%B %d, %Y at %I:%M:%S %p") %>
<div class="text-xs text-slate-500"><%= time_ago_in_words(@telegram_bot_log.created_at) %> ago</div>
</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-slate-500">Last Updated</dt>
<dd class="text-sm text-slate-900">
<%= @telegram_bot_log.updated_at.strftime("%B %d, %Y at %I:%M:%S %p") %>
<div class="text-xs text-slate-500"><%= time_ago_in_words(@telegram_bot_log.updated_at) %> ago</div>
</dd>
</div>
</div>
</div>
</div>
</div>

View File

@@ -121,6 +121,11 @@ Rails.application.routes.draw do
end
end
# Telegram bot audit logs
resources :telegram_bot_logs,
only: %i[index show],
path: "telegram-bot-logs"
mount GoodJob::Engine => "jobs"
mount PgHero::Engine => "pghero"

View File

@@ -0,0 +1,42 @@
# typed: true
class CreateTelegramBotLogs < ActiveRecord::Migration[7.2]
def change
create_table :telegram_bot_logs do |t|
# Telegram user information
t.bigint :telegram_user_id, null: false
t.string :telegram_username
t.string :telegram_first_name
t.string :telegram_last_name
t.bigint :telegram_chat_id, null: false
# Request metadata
t.timestamp :request_timestamp, null: false
t.string :status, null: false
t.text :error_message
# Performance metrics
t.float :fingerprint_computation_time
t.float :search_computation_time
t.integer :search_results_count, default: 0
# Response and image data
t.jsonb :response_data, default: {}
t.binary :processed_image_sha256
t.timestamps
# Indexes for performance
t.index :telegram_user_id
t.index :request_timestamp
t.index :search_results_count
t.index :processed_image_sha256
t.index %i[telegram_user_id request_timestamp]
end
# Foreign key constraint to BlobFile (sha256 field)
add_foreign_key :telegram_bot_logs,
:blob_files,
column: :processed_image_sha256,
primary_key: :sha256
end
end

View File

@@ -0,0 +1,6 @@
class AddPerformanceTimingToTelegramBotLogs < ActiveRecord::Migration[7.2]
def change
add_column :telegram_bot_logs, :download_time, :float
add_column :telegram_bot_logs, :image_processing_time, :float
end
end

View File

@@ -0,0 +1,5 @@
class AddTotalRequestTimeToTelegramBotLogs < ActiveRecord::Migration[7.2]
def change
add_column :telegram_bot_logs, :total_request_time, :float
end
end

View File

@@ -2324,6 +2324,52 @@ CREATE TABLE public.schema_migrations (
);
--
-- Name: telegram_bot_logs; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.telegram_bot_logs (
id bigint NOT NULL,
telegram_user_id bigint NOT NULL,
telegram_username character varying,
telegram_first_name character varying,
telegram_last_name character varying,
telegram_chat_id bigint NOT NULL,
request_timestamp timestamp without time zone NOT NULL,
status character varying NOT NULL,
error_message text,
fingerprint_computation_time double precision,
search_computation_time double precision,
search_results_count integer DEFAULT 0,
response_data jsonb DEFAULT '{}'::jsonb,
processed_image_sha256 bytea,
created_at timestamp(6) without time zone NOT NULL,
updated_at timestamp(6) without time zone NOT NULL,
download_time double precision,
image_processing_time double precision,
total_request_time double precision
);
--
-- Name: telegram_bot_logs_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.telegram_bot_logs_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: telegram_bot_logs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.telegram_bot_logs_id_seq OWNED BY public.telegram_bot_logs.id;
--
-- Name: trained_regression_models; Type: TABLE; Schema: public; Owner: -
--
@@ -3050,6 +3096,13 @@ ALTER TABLE ONLY public.pghero_query_stats ALTER COLUMN id SET DEFAULT nextval('
ALTER TABLE ONLY public.pghero_space_stats ALTER COLUMN id SET DEFAULT nextval('public.pghero_space_stats_id_seq'::regclass);
--
-- Name: telegram_bot_logs id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.telegram_bot_logs ALTER COLUMN id SET DEFAULT nextval('public.telegram_bot_logs_id_seq'::regclass);
--
-- Name: trained_regression_models id; Type: DEFAULT; Schema: public; Owner: -
--
@@ -3336,6 +3389,14 @@ ALTER TABLE ONLY public.schema_migrations
ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version);
--
-- Name: telegram_bot_logs telegram_bot_logs_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.telegram_bot_logs
ADD CONSTRAINT telegram_bot_logs_pkey PRIMARY KEY (id);
--
-- Name: trained_regression_models trained_regression_models_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -3541,6 +3602,13 @@ CREATE UNIQUE INDEX idx_on_post_file_id_thumb_type_frame_17152086d1 ON public.do
CREATE UNIQUE INDEX idx_on_post_file_id_thumbnail_id_28e9a641fb ON public.domain_post_file_bit_fingerprints USING btree (post_file_id, thumbnail_id);
--
-- Name: idx_on_telegram_user_id_request_timestamp_075ae9ab44; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX idx_on_telegram_user_id_request_timestamp_075ae9ab44 ON public.telegram_bot_logs USING btree (telegram_user_id, request_timestamp);
--
-- Name: index_blob_files_on_sha256; Type: INDEX; Schema: public; Owner: -
--
@@ -4619,6 +4687,34 @@ CREATE INDEX index_pghero_query_stats_on_database_and_captured_at ON public.pghe
CREATE INDEX index_pghero_space_stats_on_database_and_captured_at ON public.pghero_space_stats USING btree (database, captured_at);
--
-- Name: index_telegram_bot_logs_on_processed_image_sha256; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_telegram_bot_logs_on_processed_image_sha256 ON public.telegram_bot_logs USING btree (processed_image_sha256);
--
-- Name: index_telegram_bot_logs_on_request_timestamp; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_telegram_bot_logs_on_request_timestamp ON public.telegram_bot_logs USING btree (request_timestamp);
--
-- Name: index_telegram_bot_logs_on_search_results_count; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_telegram_bot_logs_on_search_results_count ON public.telegram_bot_logs USING btree (search_results_count);
--
-- Name: index_telegram_bot_logs_on_telegram_user_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_telegram_bot_logs_on_telegram_user_id ON public.telegram_bot_logs USING btree (telegram_user_id);
--
-- Name: index_trained_regression_models_on_created_at; Type: INDEX; Schema: public; Owner: -
--
@@ -5130,6 +5226,14 @@ ALTER INDEX public.index_blob_files_on_sha256 ATTACH PARTITION public.index_blob
ALTER INDEX public.index_blob_files_on_sha256 ATTACH PARTITION public.index_blob_files_63_on_sha256;
--
-- Name: telegram_bot_logs fk_rails_001ca2ed89; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.telegram_bot_logs
ADD CONSTRAINT fk_rails_001ca2ed89 FOREIGN KEY (processed_image_sha256) REFERENCES public.blob_files(sha256);
--
-- Name: domain_user_job_event_add_tracked_objects fk_rails_03ea351597; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -5449,6 +5553,9 @@ ALTER TABLE ONLY public.domain_twitter_tweets
SET search_path TO "$user", public;
INSERT INTO "schema_migrations" (version) VALUES
('20250805045947'),
('20250805044757'),
('20250731035548'),
('20250727173100'),
('20250726051748'),
('20250726051451'),

View File

@@ -0,0 +1,18 @@
# typed: true
# DO NOT EDIT MANUALLY
# This is an autogenerated file for dynamic methods in `ActiveSupport::TestCase`.
# Please instead update this file by running `bin/tapioca dsl ActiveSupport::TestCase`.
class ActiveSupport::TestCase
sig { params(fixture_name: NilClass, other_fixtures: NilClass).returns(T::Array[TelegramBotLog]) }
sig { params(fixture_name: T.any(String, Symbol), other_fixtures: NilClass).returns(TelegramBotLog) }
sig do
params(
fixture_name: T.any(String, Symbol),
other_fixtures: T.any(String, Symbol)
).returns(T::Array[TelegramBotLog])
end
def telegram_bot_logs(fixture_name = nil, *other_fixtures); end
end

View File

@@ -216,6 +216,18 @@ module GeneratedPathHelpersModule
sig { params(args: T.untyped).returns(String) }
def stats_log_entries_path(*args); end
sig { params(args: T.untyped).returns(String) }
def telegram_bot_log_path(*args); end
sig { params(args: T.untyped).returns(String) }
def telegram_bot_logs_path(*args); end
sig { params(args: T.untyped).returns(String) }
def telegram_config_edit_global_states_path(*args); end
sig { params(args: T.untyped).returns(String) }
def telegram_config_global_states_path(*args); end
sig { params(args: T.untyped).returns(String) }
def toggle_state_ip_address_role_path(*args); end

View File

@@ -216,6 +216,18 @@ module GeneratedUrlHelpersModule
sig { params(args: T.untyped).returns(String) }
def stats_log_entries_url(*args); end
sig { params(args: T.untyped).returns(String) }
def telegram_bot_log_url(*args); end
sig { params(args: T.untyped).returns(String) }
def telegram_bot_logs_url(*args); end
sig { params(args: T.untyped).returns(String) }
def telegram_config_edit_global_states_url(*args); end
sig { params(args: T.untyped).returns(String) }
def telegram_config_global_states_url(*args); end
sig { params(args: T.untyped).returns(String) }
def toggle_state_ip_address_role_url(*args); end

2249
sorbet/rbi/dsl/telegram_bot_log.rbi generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,267 @@
# typed: false
require "rails_helper"
RSpec.describe TelegramBotLogsController, type: :controller do
render_views
let(:admin_user) { create(:user, role: :admin) }
let(:regular_user) { create(:user, role: :user) }
let(:telegram_bot_log) { create(:telegram_bot_log, :successful, :with_image) }
describe "authorization" do
context "when user is not signed in" do
describe "GET #index" do
it "redirects to sign in" do
get :index
expect(response).to redirect_to(new_user_session_path)
end
end
describe "GET #show" do
it "redirects to sign in" do
get :show, params: { id: telegram_bot_log.id }
expect(response).to redirect_to(new_user_session_path)
end
end
end
context "when regular user is signed in" do
before { sign_in regular_user }
describe "GET #index" do
it "redirects to root with access denied" do
get :index
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to eq(
"You are not authorized to perform this action.",
)
end
end
describe "GET #show" do
it "redirects to root with access denied" do
get :show, params: { id: telegram_bot_log.id }
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to eq(
"You are not authorized to perform this action.",
)
end
end
end
end
context "when admin user is signed in" do
before { sign_in admin_user }
describe "GET #index" do
it "returns a success response" do
get :index
expect(response).to be_successful
expect(response).to render_template(:index)
end
it "assigns telegram_bot_logs with proper pagination" do
logs = create_list(:telegram_bot_log, 3, :successful)
get :index
expect(assigns(:telegram_bot_logs)).to be_present
expect(assigns(:telegram_bot_logs)).to respond_to(:current_page)
expect(assigns(:telegram_bot_logs).to_a).to match_array(logs)
end
it "respects limit parameter" do
create_list(:telegram_bot_log, 5, :successful)
get :index, params: { limit: 2 }
expect(assigns(:limit)).to eq(2)
expect(assigns(:telegram_bot_logs).count).to eq(2)
end
it "clamps limit parameter within bounds" do
get :index, params: { limit: 1000 }
expect(assigns(:limit)).to eq(500) # max limit
get :index, params: { limit: 0 }
expect(assigns(:limit)).to eq(1) # min limit
end
it "handles pagination parameters" do
create_list(:telegram_bot_log, 10, :successful)
get :index, params: { page: 2, limit: 5 }
expect(assigns(:telegram_bot_logs).current_page).to eq(2)
end
it "assigns status options for filtering" do
get :index
expect(assigns(:status_options)).to eq(TelegramBotLog.statuses.keys)
end
it "assigns filter params" do
filter_params = {
telegram_user_id: "123456789",
status: "successful",
start_date: "2023-01-01",
end_date: "2023-12-31",
min_results: "1",
max_results: "10",
slow_requests: "true",
}
get :index, params: filter_params
assigned_params = assigns(:filter_params)
expect(assigned_params["telegram_user_id"]).to eq("123456789")
expect(assigned_params["status"]).to eq("successful")
expect(assigned_params["start_date"]).to eq("2023-01-01")
expect(assigned_params["end_date"]).to eq("2023-12-31")
expect(assigned_params["min_results"]).to eq("1")
expect(assigned_params["max_results"]).to eq("10")
expect(assigned_params["slow_requests"]).to eq("true")
end
describe "filtering" do
let!(:user_123_log) do
create(:telegram_bot_log, :successful, telegram_user_id: 123_456_789)
end
let!(:user_456_log) do
create(:telegram_bot_log, :successful, telegram_user_id: 456_789_123)
end
let!(:error_log) { create(:telegram_bot_log, :with_error) }
let!(:no_results_log) { create(:telegram_bot_log, :no_results) }
it "filters by telegram_user_id" do
get :index, params: { telegram_user_id: "123456789" }
logs = assigns(:telegram_bot_logs).to_a
expect(logs).to include(user_123_log)
expect(logs).not_to include(user_456_log)
end
it "filters by status" do
get :index, params: { status: "error" }
logs = assigns(:telegram_bot_logs).to_a
expect(logs).to include(error_log)
expect(logs).not_to include(user_123_log)
end
it "filters by search results count range" do
high_results_log =
create(:telegram_bot_log, :successful, search_results_count: 5)
low_results_log =
create(:telegram_bot_log, :successful, search_results_count: 1)
get :index, params: { min_results: "3" }
logs = assigns(:telegram_bot_logs).to_a
expect(logs).to include(high_results_log)
expect(logs).not_to include(low_results_log)
end
it "filters by date range" do
old_log =
create(
:telegram_bot_log,
:successful,
request_timestamp: 1.week.ago,
)
new_log =
create(:telegram_bot_log, :successful, request_timestamp: 1.day.ago)
get :index,
params: {
start_date: 3.days.ago.to_date.to_s,
end_date: Date.current.to_s,
}
logs = assigns(:telegram_bot_logs).to_a
expect(logs).to include(new_log)
expect(logs).not_to include(old_log)
end
it "filters slow requests" do
fast_log =
create(
:telegram_bot_log,
:successful,
fingerprint_computation_time: 0.1,
search_computation_time: 0.2,
)
slow_log =
create(
:telegram_bot_log,
:successful,
fingerprint_computation_time: 0.8,
search_computation_time: 0.9,
)
get :index, params: { slow_requests: "true" }
logs = assigns(:telegram_bot_logs).to_a
expect(logs).to include(slow_log)
expect(logs).not_to include(fast_log)
end
end
describe "includes associations" do
it "includes processed_image association" do
create(:telegram_bot_log, :with_image)
get :index
# Verify the association is loaded
expect(
assigns(:telegram_bot_logs).first&.processed_image,
).to be_present
end
end
describe "error handling" do
it "handles invalid filter parameters gracefully" do
get :index,
params: {
start_date: "invalid-date",
min_results: "not-a-number",
}
expect(response).to be_successful
expect(assigns(:telegram_bot_logs)).not_to be_nil
end
end
end
describe "GET #show" do
let(:telegram_bot_log) do
create(:telegram_bot_log, :successful, :with_image)
end
it "returns a success response" do
get :show, params: { id: telegram_bot_log.id }
expect(response).to be_successful
expect(response).to render_template(:show)
end
it "assigns the requested telegram_bot_log" do
get :show, params: { id: telegram_bot_log.id }
expect(assigns(:telegram_bot_log)).to eq(telegram_bot_log)
end
it "includes processed_image association" do
get :show, params: { id: telegram_bot_log.id }
# Verify the association is loaded
expect(assigns(:telegram_bot_log).processed_image).to be_present
end
it "handles non-existent log gracefully" do
get :show, params: { id: 999_999 }
expect(response).to redirect_to(telegram_bot_logs_path)
expect(flash[:alert]).to eq("Telegram bot log not found.")
end
end
end
describe "performance" do
context "with many records" do
before { create_list(:telegram_bot_log, 100, :successful) }
it "loads records successfully with many logs" do
sign_in admin_user
get :index, params: { limit: 50 }
expect(response).to be_successful
expect(assigns(:telegram_bot_logs).count).to eq(50)
end
end
end
end

View File

@@ -0,0 +1,97 @@
# typed: false
FactoryBot.define do
factory :telegram_bot_log do
sequence(:telegram_user_id) { |n| 100_000_000 + n }
sequence(:telegram_chat_id) { |n| 200_000_000 + n }
telegram_username { "user#{SecureRandom.alphanumeric(6)}" }
telegram_first_name { "Test" }
telegram_last_name { "User" }
request_timestamp { Time.current }
status { :success }
search_results_count { 3 }
download_time { 0.08 }
image_processing_time { 0.05 }
fingerprint_computation_time { 0.15 }
search_computation_time { 0.25 }
total_request_time { 0.53 }
response_data { { matches: 3, threshold: 90 } }
trait :with_image do
association :processed_image, factory: %i[blob_file image_blob]
end
trait :successful do
status { :success }
search_results_count { 5 }
download_time { 0.06 }
image_processing_time { 0.04 }
fingerprint_computation_time { 0.12 }
search_computation_time { 0.18 }
total_request_time { 0.40 }
response_data { { matches: 5, threshold: 90, posts: [1, 2, 3, 4, 5] } }
end
trait :with_no_results do
status { :no_results }
search_results_count { 0 }
download_time { 0.07 }
image_processing_time { 0.03 }
fingerprint_computation_time { 0.10 }
search_computation_time { 0.05 }
total_request_time { 0.25 }
response_data { { matches: 0, threshold: 90 } }
end
trait :with_error do
status { :error }
search_results_count { 0 }
download_time { 0.05 }
image_processing_time { nil }
fingerprint_computation_time { nil }
search_computation_time { nil }
total_request_time { 0.15 }
error_message { "Failed to process image: Invalid format" }
response_data { { error: "Invalid image format" } }
end
trait :invalid_image do
status { :invalid_image }
search_results_count { 0 }
download_time { 0.04 }
image_processing_time { nil }
fingerprint_computation_time { nil }
search_computation_time { nil }
total_request_time { 0.12 }
error_message { "Image format not supported" }
response_data { { error: "Unsupported format" } }
end
trait :minimal_user_info do
telegram_username { nil }
telegram_first_name { nil }
telegram_last_name { nil }
end
trait :username_only do
telegram_first_name { nil }
telegram_last_name { nil }
telegram_username { "anonymous_user" }
end
trait :slow_processing do
download_time { 0.5 }
image_processing_time { 0.3 }
fingerprint_computation_time { 2.5 }
search_computation_time { 1.8 }
total_request_time { 5.1 }
end
trait :fast_processing do
download_time { 0.01 }
image_processing_time { 0.005 }
fingerprint_computation_time { 0.02 }
search_computation_time { 0.01 }
total_request_time { 0.045 }
end
end
end

108
spec/lib/stopwatch_spec.rb Normal file
View File

@@ -0,0 +1,108 @@
# typed: false
require "rails_helper"
RSpec.describe Stopwatch do
describe ".start" do
it "creates a new stopwatch with current time as start time" do
before_time = Time.current
stopwatch = Stopwatch.start
after_time = Time.current
expect(stopwatch.start_time).to be_between(before_time, after_time)
end
end
describe "#elapsed" do
it "calculates elapsed time correctly" do
start_time = Time.parse("2023-01-01 10:00:00")
stopwatch = Stopwatch.new(start_time)
# Mock Time.current for this specific stopwatch instance
allow(stopwatch).to receive(:elapsed) { 2.5 }
expect(stopwatch.elapsed).to eq(2.5)
end
it "returns non-negative elapsed time" do
stopwatch = Stopwatch.start
expect(stopwatch.elapsed).to be >= 0
end
end
describe "#elapsed_ms" do
it "formats milliseconds correctly" do
stopwatch = Stopwatch.start
allow(stopwatch).to receive(:elapsed) { 1.2345 }
expect(stopwatch.elapsed_ms).to eq("1234.5ms")
end
it "handles very small times" do
stopwatch = Stopwatch.start
allow(stopwatch).to receive(:elapsed) { 0.001 }
expect(stopwatch.elapsed_ms).to eq("1.0ms")
end
end
describe "#elapsed_s" do
it "formats seconds with 3 decimal places" do
stopwatch = Stopwatch.start
allow(stopwatch).to receive(:elapsed) { 1.2345 }
expect(stopwatch.elapsed_s).to eq("1.234s")
end
it "formats small times to 3 decimal places" do
stopwatch = Stopwatch.start
allow(stopwatch).to receive(:elapsed) { 0.001234 }
expect(stopwatch.elapsed_s).to eq("0.001s")
end
it "formats zero time correctly" do
stopwatch = Stopwatch.start
allow(stopwatch).to receive(:elapsed) { 0.0 }
expect(stopwatch.elapsed_s).to eq("0.000s")
end
end
describe "#start_time" do
it "returns the time when the stopwatch was started" do
start_time = Time.parse("2023-01-01 10:00:00")
stopwatch = Stopwatch.new(start_time)
expect(stopwatch.start_time).to eq(start_time)
end
end
describe "practical usage" do
it "provides the expected API usage pattern" do
# Test the exact usage pattern the user requested
download_time = Stopwatch.start
# The elapsed method should return a float representing seconds
duration = download_time.elapsed
expect(duration).to be_a(Float)
expect(duration).to be >= 0
# Should provide convenient formatting methods
expect(download_time.elapsed_s).to match(/^\d+\.\d{3}s$/)
expect(download_time.elapsed_ms).to match(/^\d+\.\d+ms$/)
end
it "can be used for timing operations" do
# Simulate timing an operation
operation_timer = Stopwatch.start
# Simulate work (just check the timing interface works)
# In real usage: do_some_work()
sleep(0.001) # Minimal sleep to ensure some time passes
execution_time = operation_timer.elapsed
expect(execution_time).to be > 0
expect(execution_time).to be < 1 # Should be very fast
end
end
end

View File

@@ -0,0 +1,466 @@
# typed: false
require "rails_helper"
RSpec.describe TelegramBotLog, type: :model do
describe "validations" do
subject { build(:telegram_bot_log) }
it { should validate_presence_of(:telegram_user_id) }
it { should validate_presence_of(:telegram_chat_id) }
it { should validate_presence_of(:request_timestamp) }
it { should validate_presence_of(:status) }
it { should validate_presence_of(:search_results_count) }
it do
should validate_numericality_of(
:search_results_count,
).is_greater_than_or_equal_to(0)
end
it do
should validate_numericality_of(
:fingerprint_computation_time,
).is_greater_than(0).allow_nil
end
it do
should validate_numericality_of(:search_computation_time).is_greater_than(
0,
).allow_nil
end
it do
should validate_length_of(:processed_image_sha256).is_equal_to(
32,
).allow_nil
end
it do
should define_enum_for(:status)
.with_values(
success: "success",
error: "error",
no_results: "no_results",
invalid_image: "invalid_image",
)
.backed_by_column_of_type(:string)
.with_prefix(:status)
end
context "when fingerprint_computation_time is present" do
it "is valid with a positive value" do
subject.fingerprint_computation_time = 0.5
expect(subject).to be_valid
end
it "is invalid with zero" do
subject.fingerprint_computation_time = 0
expect(subject).not_to be_valid
expect(subject.errors[:fingerprint_computation_time]).to include(
"must be greater than 0",
)
end
it "is invalid with negative value" do
subject.fingerprint_computation_time = -0.1
expect(subject).not_to be_valid
expect(subject.errors[:fingerprint_computation_time]).to include(
"must be greater than 0",
)
end
end
context "when search_computation_time is present" do
it "is valid with a positive value" do
subject.search_computation_time = 0.3
expect(subject).to be_valid
end
it "is invalid with zero" do
subject.search_computation_time = 0
expect(subject).not_to be_valid
expect(subject.errors[:search_computation_time]).to include(
"must be greater than 0",
)
end
it "is invalid with negative value" do
subject.search_computation_time = -0.1
expect(subject).not_to be_valid
expect(subject.errors[:search_computation_time]).to include(
"must be greater than 0",
)
end
end
context "when download_time is present" do
it "is valid with a positive value" do
subject.download_time = 0.2
expect(subject).to be_valid
end
it "is invalid with zero" do
subject.download_time = 0
expect(subject).not_to be_valid
expect(subject.errors[:download_time]).to include(
"must be greater than 0",
)
end
it "is invalid with negative value" do
subject.download_time = -0.1
expect(subject).not_to be_valid
expect(subject.errors[:download_time]).to include(
"must be greater than 0",
)
end
end
context "when image_processing_time is present" do
it "is valid with a positive value" do
subject.image_processing_time = 0.1
expect(subject).to be_valid
end
it "is invalid with zero" do
subject.image_processing_time = 0
expect(subject).not_to be_valid
expect(subject.errors[:image_processing_time]).to include(
"must be greater than 0",
)
end
it "is invalid with negative value" do
subject.image_processing_time = -0.05
expect(subject).not_to be_valid
expect(subject.errors[:image_processing_time]).to include(
"must be greater than 0",
)
end
end
context "when total_request_time is present" do
it "is valid with a positive value" do
subject.total_request_time = 1.5
expect(subject).to be_valid
end
it "is invalid with zero" do
subject.total_request_time = 0
expect(subject).not_to be_valid
expect(subject.errors[:total_request_time]).to include(
"must be greater than 0",
)
end
it "is invalid with negative value" do
subject.total_request_time = -0.1
expect(subject).not_to be_valid
expect(subject.errors[:total_request_time]).to include(
"must be greater than 0",
)
end
end
end
describe "associations" do
it { should belong_to(:processed_image).class_name("BlobFile").optional }
context "with processed image" do
let(:blob_file) { create(:blob_file, :image_blob) }
let(:log) { build(:telegram_bot_log, processed_image: blob_file) }
it "associates with BlobFile correctly" do
expect(log.processed_image).to eq(blob_file)
expect(log.processed_image_sha256).to eq(blob_file.sha256)
end
end
end
describe "scopes" do
let!(:user1_log1) do
create(:telegram_bot_log, telegram_user_id: 111, status: :success)
end
let!(:user1_log2) do
create(:telegram_bot_log, telegram_user_id: 111, status: :error)
end
let!(:user2_log) do
create(:telegram_bot_log, telegram_user_id: 222, status: :success)
end
let!(:no_results_log) { create(:telegram_bot_log, :with_no_results) }
let!(:old_log) { create(:telegram_bot_log, request_timestamp: 1.week.ago) }
let!(:recent_log) do
create(:telegram_bot_log, request_timestamp: 1.hour.ago)
end
describe ".for_user" do
it "returns logs for specific user" do
expect(TelegramBotLog.for_user(111)).to contain_exactly(
user1_log1,
user1_log2,
)
expect(TelegramBotLog.for_user(222)).to contain_exactly(user2_log)
end
end
describe ".successful" do
it "returns only successful logs" do
successful_logs = TelegramBotLog.successful
expect(successful_logs).to include(user1_log1, user2_log, recent_log)
expect(successful_logs).not_to include(user1_log2, no_results_log)
end
end
describe ".with_results" do
it "returns logs with search results count > 0" do
with_results = TelegramBotLog.with_results
expect(with_results).not_to include(no_results_log)
expect(with_results).to include(user1_log1, user2_log)
end
end
describe ".recent" do
it "orders by request_timestamp desc" do
recent_logs = TelegramBotLog.recent
expect(recent_logs.first.request_timestamp).to be >
recent_logs.last.request_timestamp
end
end
describe ".by_date_range" do
it "returns logs within date range" do
start_date = 2.days.ago
end_date = Time.current
logs_in_range = TelegramBotLog.by_date_range(start_date, end_date)
expect(logs_in_range).to include(recent_log)
expect(logs_in_range).not_to include(old_log)
end
end
end
describe "helper methods" do
describe "#user_display_name" do
context "with first and last name" do
let(:log) do
build(
:telegram_bot_log,
telegram_first_name: "John",
telegram_last_name: "Doe",
)
end
it "returns full name" do
expect(log.user_display_name).to eq("John Doe")
end
end
context "with only first name" do
let(:log) do
build(
:telegram_bot_log,
telegram_first_name: "John",
telegram_last_name: nil,
)
end
it "returns first name" do
expect(log.user_display_name).to eq("John")
end
end
context "with only username" do
let(:log) do
build(:telegram_bot_log, :username_only, telegram_username: "johndoe")
end
it "returns username with @ prefix" do
expect(log.user_display_name).to eq("@johndoe")
end
end
context "with minimal user info" do
let(:log) do
build(
:telegram_bot_log,
:minimal_user_info,
telegram_user_id: 123_456,
)
end
it "returns user ID format" do
expect(log.user_display_name).to eq("User 123456")
end
end
end
describe "#has_performance_metrics?" do
context "with core timing metrics" do
let(:log) { build(:telegram_bot_log, :successful) }
it "returns true" do
expect(log.has_performance_metrics?).to be true
end
end
context "with missing download timing" do
let(:log) { build(:telegram_bot_log, download_time: nil) }
it "returns false" do
expect(log.has_performance_metrics?).to be false
end
end
context "with missing fingerprint timing" do
let(:log) do
build(:telegram_bot_log, fingerprint_computation_time: nil)
end
it "returns false" do
expect(log.has_performance_metrics?).to be false
end
end
context "with missing search timing" do
let(:log) { build(:telegram_bot_log, search_computation_time: nil) }
it "returns false" do
expect(log.has_performance_metrics?).to be false
end
end
context "with core timings missing" do
let(:log) { build(:telegram_bot_log, :with_error) }
it "returns false" do
expect(log.has_performance_metrics?).to be false
end
end
end
describe "#has_complete_performance_metrics?" do
context "with all timing metrics" do
let(:log) { build(:telegram_bot_log, :successful) }
it "returns true" do
expect(log.has_complete_performance_metrics?).to be true
end
end
context "with missing image_processing_time" do
let(:log) { build(:telegram_bot_log, image_processing_time: nil) }
it "returns false" do
expect(log.has_complete_performance_metrics?).to be false
end
end
context "with missing any timing" do
let(:log) { build(:telegram_bot_log, download_time: nil) }
it "returns false" do
expect(log.has_complete_performance_metrics?).to be false
end
end
end
end
describe "database constraints and indexes" do
it "has proper table name" do
expect(TelegramBotLog.table_name).to eq("telegram_bot_logs")
end
context "foreign key constraint" do
let(:blob_file) { create(:blob_file, :image_blob) }
it "allows valid BlobFile association" do
log = build(:telegram_bot_log, processed_image_sha256: blob_file.sha256)
expect { log.save! }.not_to raise_error
end
it "prevents invalid sha256 reference" do
invalid_sha256 = "\x00" * 32 # Invalid sha256
log = build(:telegram_bot_log, processed_image_sha256: invalid_sha256)
expect { log.save! }.to raise_error(ActiveRecord::InvalidForeignKey)
end
end
end
describe "factory traits" do
it "creates successful log with correct attributes" do
log = build(:telegram_bot_log, :successful)
expect(log.status).to eq("success")
expect(log.search_results_count).to be > 0
expect(log.has_performance_metrics?).to be true
end
it "creates error log with correct attributes" do
log = build(:telegram_bot_log, :with_error)
expect(log.status).to eq("error")
expect(log.search_results_count).to eq(0)
expect(log.error_message).to be_present
expect(log.has_performance_metrics?).to be false
end
it "creates log with image association" do
log = build(:telegram_bot_log, :with_image)
expect(log.processed_image).to be_present
expect(log.processed_image.content_type).to eq("image/jpeg")
end
it "creates log with minimal user info" do
log = build(:telegram_bot_log, :minimal_user_info)
expect(log.telegram_username).to be_nil
expect(log.telegram_first_name).to be_nil
expect(log.telegram_last_name).to be_nil
expect(log.user_display_name).to match(/User \d+/)
end
end
describe "realistic usage scenarios" do
context "successful image search" do
let(:blob_file) { create(:blob_file, :image_blob) }
let(:log) do
create(
:telegram_bot_log,
:successful,
:with_image,
processed_image: blob_file,
response_data: {
matches: 3,
threshold: 90,
posts: [
{ id: 1, similarity: 95.2, url: "https://example.com/1" },
{ id: 2, similarity: 92.1, url: "https://example.com/2" },
{ id: 3, similarity: 90.5, url: "https://example.com/3" },
],
},
)
end
it "saves and retrieves correctly" do
expect(log).to be_persisted
expect(log.status_success?).to be true
expect(log.processed_image).to eq(blob_file)
expect(log.response_data["matches"]).to eq(3)
expect(log.total_request_time).to be > 0
end
end
context "failed image processing" do
let(:log) do
create(
:telegram_bot_log,
:with_error,
error_message: "Corrupted image file",
)
end
it "properly records error state" do
expect(log.status_error?).to be true
expect(log.search_results_count).to eq(0)
expect(log.error_message).to eq("Corrupted image file")
expect(log.has_performance_metrics?).to be false
end
end
end
end

View File

@@ -0,0 +1,68 @@
# typed: false
require "rails_helper"
RSpec.describe TelegramBotLogPolicy, type: :policy do
let(:admin_user) { create(:user, role: :admin) }
let(:regular_user) { create(:user, role: :user) }
let(:telegram_bot_log) { create(:telegram_bot_log, :successful) }
describe "permissions" do
describe "index?" do
it "grants access for admin users" do
policy = TelegramBotLogPolicy.new(admin_user, TelegramBotLog)
expect(policy.index?).to be true
end
it "denies access for regular users" do
policy = TelegramBotLogPolicy.new(regular_user, TelegramBotLog)
expect(policy.index?).to be false
end
end
describe "show?" do
it "grants access for admin users" do
policy = TelegramBotLogPolicy.new(admin_user, telegram_bot_log)
expect(policy.show?).to be true
end
it "denies access for regular users" do
policy = TelegramBotLogPolicy.new(regular_user, telegram_bot_log)
expect(policy.show?).to be false
end
end
describe "other actions" do
it "denies new/create/edit/update/destroy for all users" do
admin_policy = TelegramBotLogPolicy.new(admin_user, telegram_bot_log)
regular_policy =
TelegramBotLogPolicy.new(regular_user, telegram_bot_log)
# These methods are inherited from ApplicationPolicy and should be false
expect(admin_policy.create?).to be false
expect(admin_policy.new?).to be false
expect(admin_policy.edit?).to be false
expect(admin_policy.update?).to be false
expect(admin_policy.destroy?).to be false
expect(regular_policy.create?).to be false
expect(regular_policy.new?).to be false
expect(regular_policy.edit?).to be false
expect(regular_policy.update?).to be false
expect(regular_policy.destroy?).to be false
end
end
end
describe "scope" do
let!(:log1) { create(:telegram_bot_log, :successful) }
let!(:log2) { create(:telegram_bot_log, :with_error) }
it "returns all logs for admin users" do
scope = Pundit.policy_scope(admin_user, TelegramBotLog)
expect(scope).to include(log1, log2)
end
# Note: The policy scope returns scope.all for all users, but actual access control
# is handled at the controller level via authorize calls
end
end

31
test/fixtures/telegram_bot_logs.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
telegram_user_id:
telegram_username: MyString
telegram_first_name: MyString
telegram_last_name: MyString
telegram_chat_id:
request_timestamp: 2025-07-31 03:55:48
fingerprint_computation_time: 1.5
search_computation_time: 1.5
search_results_count: 1
response_data:
status: MyString
error_message: MyText
processed_image_sha256:
two:
telegram_user_id:
telegram_username: MyString
telegram_first_name: MyString
telegram_last_name: MyString
telegram_chat_id:
request_timestamp: 2025-07-31 03:55:48
fingerprint_computation_time: 1.5
search_computation_time: 1.5
search_results_count: 1
response_data:
status: MyString
error_message: MyText
processed_image_sha256:

View File

@@ -0,0 +1,7 @@
require "test_helper"
class TelegramBotLogTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end