diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..c2a4b6b
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,36 @@
+name: Test Redmine ONE Webhook Plugin
+
+on:
+ push:
+ branches: [ main, master, develop ]
+ pull_request:
+ branches: [ main, master, develop ]
+
+jobs:
+ test:
+ runs-on: ubuntu-24.04
+
+ strategy:
+ matrix:
+ database: ['mysql:8.0']
+ ruby-version: ['3.2']
+ redmine-version: ['5.1-stable']
+
+ steps:
+ - name: Setup Redmine
+ uses: hidakatsuya/action-setup-redmine@v3.0.1
+ with:
+ repository: 'redmine/redmine'
+ version: ${{ matrix.redmine-version }}
+ database: ${{ matrix.database }}
+ ruby-version: ${{ matrix.ruby-version }}
+
+ - name: Checkout plugin
+ uses: actions/checkout@v4
+ with:
+ path: plugins/redmine_one_webhook
+
+ - name: Run plugin tests
+ run: |
+ bundle install
+ bin/rails redmine:plugins:test NAME=redmine_one_webhook RAILS_ENV=test
diff --git a/app/views/settings/_redmine_one_webhook_settings.html.erb b/app/views/settings/_redmine_one_webhook_settings.html.erb
index 8b35970..7a2f729 100644
--- a/app/views/settings/_redmine_one_webhook_settings.html.erb
+++ b/app/views/settings/_redmine_one_webhook_settings.html.erb
@@ -3,7 +3,7 @@
|
- <%= check_box_tag 'settings[enabled]', '1', @settings['enabled'] == '1' %>When enabled, overtime logs will be sent to ONE system
+ <%= check_box_tag 'settings[enabled]', '1', @settings['enabled'] == '1', style: 'height: 100%; margin-right: 5px;' %>When enabled, overtime logs will be sent to ONE system
|
diff --git a/lib/redmine_webhook/author_wrapper.rb b/lib/redmine_webhook/author_wrapper.rb
index 418ee76..9905697 100644
--- a/lib/redmine_webhook/author_wrapper.rb
+++ b/lib/redmine_webhook/author_wrapper.rb
@@ -21,11 +21,8 @@ def to_hash
end
def icon_url
- if @author.mail.blank?
- icon_url = nil
- else
- icon_url = gravatar_url(@author.mail)
- end
+ return nil if @author.mail.blank?
+ gravatar_url(@author.mail)
end
end
end
diff --git a/lib/redmine_webhook/time_entry_wrapper.rb b/lib/redmine_webhook/time_entry_wrapper.rb
index 9a238a9..4da0c02 100644
--- a/lib/redmine_webhook/time_entry_wrapper.rb
+++ b/lib/redmine_webhook/time_entry_wrapper.rb
@@ -61,7 +61,7 @@ def issue_hash
def custom_field_values_hash
return [] unless @time_entry.custom_field_values
- @time_entry.custom_field_values.collect do |value|
+ @time_entry.custom_field_values.select { |cfv| cfv.value.present? && cfv.value.to_s != '0' }.collect do |value|
RedmineWebhook::CustomFieldValueWrapper.new(value).to_hash
end
end
diff --git a/lib/redmine_webhook/webhook_listener.rb b/lib/redmine_webhook/webhook_listener.rb
index 79f9b71..08a478f 100644
--- a/lib/redmine_webhook/webhook_listener.rb
+++ b/lib/redmine_webhook/webhook_listener.rb
@@ -5,6 +5,11 @@
module RedmineWebhook
class WebhookListener < Redmine::Hook::Listener
+ # Make new public for testing (Redmine::Hook::Listener makes new private by default)
+ def self.new(*args)
+ super
+ end
+
# Configurable overtime activity names (case-insensitive)
OVERTIME_ACTIVITIES = ['overtime', 'ot'].freeze
@@ -198,7 +203,7 @@ def get_custom_field_value(time_entry, field_names)
time_entry.custom_field_values.each do |cfv|
field_name = cfv.custom_field.name.to_s.downcase.strip
- if field_names.any? { |fn| field_name.include?(fn) }
+ if field_names.any? { |fn| field_name.include?(fn.to_s.downcase) }
return cfv.value
end
end
diff --git a/run_tests.sh b/run_tests.sh
new file mode 100755
index 0000000..188c219
--- /dev/null
+++ b/run_tests.sh
@@ -0,0 +1,81 @@
+#!/bin/bash
+# Test runner script for Redmine ONE Webhook Plugin
+# Usage: ./run_tests.sh [test_file]
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+
+PLUGIN_NAME="redmine_one_webhook"
+
+echo -e "${GREEN}Running Redmine ONE Webhook Plugin Tests${NC}"
+echo "=========================================="
+
+# Check if running in Docker or locally
+if [ -f "/.dockerenv" ] || [ -n "$DOCKER_CONTAINER" ]; then
+ # Running inside Docker container
+ REDMINE_ROOT="/usr/src/redmine"
+ cd "$REDMINE_ROOT"
+
+ if [ -n "$1" ]; then
+ # Run specific test file
+ TEST_FILE="$1"
+ echo -e "${YELLOW}Running test: $TEST_FILE${NC}"
+ RAILS_ENV=test ruby -Itest "plugins/$PLUGIN_NAME/test/$TEST_FILE"
+ else
+ # Run all plugin tests
+ echo -e "${YELLOW}Running all tests...${NC}"
+ RAILS_ENV=test rake redmine:plugins:test NAME=$PLUGIN_NAME
+ fi
+else
+ # Running locally - use Docker Compose
+ if [ ! -d "../pms" ]; then
+ echo -e "${RED}Error: Cannot find pms directory${NC}"
+ echo "Please ensure docker-compose setup exists"
+ exit 1
+ fi
+
+ echo -e "${YELLOW}Running tests via Docker Compose...${NC}"
+ cd ../pms
+
+ # Check if test database is configured
+ TEST_DB_CONFIGURED=$(docker-compose exec -T redmine bash -c "grep -q 'test:' /usr/src/redmine/config/database.yml && echo 'yes' || echo 'no'" 2>/dev/null || echo "no")
+
+ if [ "$TEST_DB_CONFIGURED" = "no" ]; then
+ echo -e "${RED}Error: Test database not configured${NC}"
+ echo -e "${YELLOW}Please run './setup_test.sh' first${NC}"
+ exit 1
+ fi
+
+ if [ -n "$1" ]; then
+ # Run specific test file
+ echo -e "${BLUE}Running test file: $1${NC}"
+ # Add mocha to load path (it's not in Gemfile.lock due to --without test)
+ docker-compose exec -T redmine bash -c "cd /usr/src/redmine && RUBYLIB='/usr/local/bundle/gems/mocha-3.0.1/lib' RAILS_ENV=test ruby -Itest plugins/$PLUGIN_NAME/test/$1"
+ else
+ # Run all tests
+ echo -e "${BLUE}Running all tests...${NC}"
+ # Add mocha to load path (it's not in Gemfile.lock due to --without test)
+ docker-compose exec -T redmine bash -c "cd /usr/src/redmine && RUBYLIB='/usr/local/bundle/gems/mocha-3.0.1/lib' RAILS_ENV=test rake redmine:plugins:test NAME=$PLUGIN_NAME"
+ fi
+
+ TEST_EXIT_CODE=$?
+
+ if [ $TEST_EXIT_CODE -eq 0 ]; then
+ echo ""
+ echo -e "${GREEN}✓ All tests passed!${NC}"
+ else
+ echo ""
+ echo -e "${RED}✗ Some tests failed${NC}"
+ fi
+
+ exit $TEST_EXIT_CODE
+fi
+
+echo -e "${GREEN}Tests completed!${NC}"
diff --git a/setup_test.sh b/setup_test.sh
new file mode 100755
index 0000000..bca856c
--- /dev/null
+++ b/setup_test.sh
@@ -0,0 +1,89 @@
+#!/bin/bash
+# Setup test environment for Redmine plugin testing
+# Run this ONCE after docker-compose up for the first time
+
+set -e
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+echo -e "${GREEN}=== Setting up Redmine Test Environment ===${NC}"
+echo ""
+
+# Check if we're in the right directory
+if [ ! -f "init.rb" ]; then
+ echo -e "${RED}Error: Must run from plugin directory${NC}"
+ exit 1
+fi
+
+cd ../pms
+
+# Check if containers are running
+if ! docker-compose ps redmine | grep -q "Up"; then
+ echo -e "${YELLOW}Starting containers...${NC}"
+ docker-compose up -d
+ echo "Waiting for containers to be ready..."
+ sleep 15
+fi
+
+echo -e "${BLUE}Step 1: Configuring test database...${NC}"
+docker-compose exec -T redmine bash -c "
+cd /usr/src/redmine
+if ! grep -q 'test:' config/database.yml; then
+ cat >> config/database.yml << 'EOF'
+
+test:
+ adapter: mysql2
+ host: \"db\"
+ port: \"3306\"
+ username: \"redmine\"
+ password: \"redmine_password\"
+ database: \"redmine_test\"
+ encoding: utf8mb4
+EOF
+ echo '✓ Test database configuration added'
+else
+ echo '✓ Test database already configured'
+fi
+"
+
+echo -e "${BLUE}Step 2: Creating test database...${NC}"
+docker-compose exec -T redmine bash -c "
+cd /usr/src/redmine
+echo 'Creating test database...'
+RAILS_ENV=test bin/rails db:create 2>&1 | grep -v 'already exists' | head -1 || echo '✓ Database exists'
+echo 'Running migrations...'
+RAILS_ENV=test bin/rails db:migrate 2>&1 | tail -5
+"
+
+echo -e "${BLUE}Step 3: Installing test dependencies and patching Gemfile.lock...${NC}"
+docker-compose exec -T redmine bash -c "
+cd /usr/src/redmine
+echo 'Installing compatible minitest 5.27.0...'
+gem install minitest -v 5.27.0 --no-document 2>&1 | tail -1
+echo 'Installing mocha gem...'
+gem install mocha --no-document 2>&1 | tail -1
+echo 'Patching Gemfile.lock to use minitest 5.27.0...'
+# Replace minitest 6.0.1 with 5.27.0 in Gemfile.lock
+sed -i 's/minitest (6\\.0\\.1)/minitest (5.27.0)/g' Gemfile.lock
+sed -i 's/minitest (~> 6\\.0)/minitest (~> 5.27)/g' Gemfile.lock
+echo '✓ Test dependencies installed and Gemfile.lock patched'
+"
+
+echo -e "${BLUE}Step 4: Loading default test data...${NC}"
+docker-compose exec -T redmine bash -c "
+cd /usr/src/redmine
+RAILS_ENV=test bin/rails redmine:load_default_data REDMINE_LANG=en 2>&1 | grep -E '(Default|Select)' || echo '✓ Data loaded'
+"
+
+echo ""
+echo -e "${GREEN}✓ Setup complete!${NC}"
+echo ""
+echo -e "${YELLOW}You can now run tests with:${NC}"
+echo -e " ${BLUE}cd $(dirname $0) && ./run_tests.sh${NC}"
+echo ""
+echo -e "${YELLOW}Note: This setup persists across 'docker-compose restart'${NC}"
+echo -e "${YELLOW}Only need to run this again after 'docker-compose down'${NC}"
diff --git a/test/integration/webhook_integration_test.rb b/test/integration/webhook_integration_test.rb
new file mode 100644
index 0000000..be3bfd6
--- /dev/null
+++ b/test/integration/webhook_integration_test.rb
@@ -0,0 +1,299 @@
+require File.expand_path('../../test_helper', __FILE__)
+
+class WebhookIntegrationTest < ActiveSupport::TestCase
+ def setup
+ @webhook_url = RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_URL
+ @webhook_secret = RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_SECRET
+ setup_plugin_settings(
+ enabled: true,
+ webhook_url: @webhook_url,
+ webhook_secret: @webhook_secret
+ )
+
+ @user = create_test_user
+ @project = create_test_project
+
+ # Add user as project member to allow time entry logging
+ unless @project.users.include?(@user)
+ role = Role.first || Role.create!(name: 'Test Role', permissions: [:log_time, :view_time_entries])
+ Member.create!(user: @user, project: @project, roles: [role])
+ end
+
+ @issue = create_test_issue(@project)
+ @overtime_activity = create_overtime_activity
+ @start_field, @end_field = create_custom_fields_for_time_entry
+ end
+
+ def teardown
+ clear_plugin_settings
+ end
+
+ # ============================================
+ # CREATE WEBHOOK FLOW TESTS
+ # ============================================
+
+ test "should send create webhook when time entry is created from log time page" do
+ time_entry = TimeEntry.new(
+ project: @project,
+ issue: @issue,
+ user: @user,
+ activity: @overtime_activity,
+ hours: 2.0,
+ spent_on: Date.today,
+ comments: 'Integration test overtime'
+ )
+ time_entry.custom_field_values = {
+ @start_field.id.to_s => RedmineWebhook::TestHelper::DEFAULT_START_TIME,
+ @end_field.id.to_s => RedmineWebhook::TestHelper::DEFAULT_END_TIME
+ }
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ listener = RedmineWebhook::WebhookListener.new
+ listener.controller_timelog_edit_before_save(time_entry: time_entry)
+
+ # Save the time entry
+ time_entry.save!
+
+ wait_for_webhook
+
+ # Verify webhook was sent (in real scenario with WebMock, we'd verify the request)
+ assert true
+ end
+
+ test "should send create webhook when time entry is created from issue edit" do
+ time_entry = TimeEntry.new(
+ project: @project,
+ issue: @issue,
+ user: @user,
+ activity: @overtime_activity,
+ hours: 1.5,
+ spent_on: Date.today
+ )
+ time_entry.custom_field_values = {
+ @start_field.id.to_s => '18:00',
+ @end_field.id.to_s => RedmineWebhook::TestHelper::DEFAULT_END_TIME_ALT
+ }
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ listener = RedmineWebhook::WebhookListener.new
+ listener.controller_issues_edit_after_save(time_entry: time_entry)
+
+ time_entry.save!
+
+ wait_for_webhook
+
+ assert true
+ end
+
+ # ============================================
+ # UPDATE WEBHOOK FLOW TESTS
+ # ============================================
+
+ test "should send update webhook when time entry is updated" do
+ time_entry = create_overtime_time_entry
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ listener = RedmineWebhook::WebhookListener.new
+
+ # Update the time entry
+ time_entry.update(hours: 3.0, comments: 'Updated overtime')
+
+ listener.controller_timelog_edit_after_save(time_entry: time_entry)
+
+ wait_for_webhook
+
+ assert true
+ end
+
+ test "should send update webhook when time entry is bulk edited" do
+ time_entry1 = create_overtime_time_entry
+ time_entry2 = create_overtime_time_entry
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ listener = RedmineWebhook::WebhookListener.new
+ listener.controller_timelog_bulk_edit_after_save(time_entries: [time_entry1, time_entry2])
+
+ wait_for_webhook(timeout: 3)
+
+ assert true
+ end
+
+ # ============================================
+ # DELETE WEBHOOK FLOW TESTS
+ # ============================================
+
+ test "should send delete webhook when time entry is deleted" do
+ time_entry = create_overtime_time_entry
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ time_entry.destroy
+
+ wait_for_webhook
+
+ assert true
+ end
+
+ # ============================================
+ # PAYLOAD VERIFICATION TESTS
+ # ============================================
+
+ test "should include correct payload structure in webhook" do
+ time_entry = create_overtime_time_entry(
+ hours: 2.5,
+ start_time: RedmineWebhook::TestHelper::DEFAULT_START_TIME,
+ end_time: RedmineWebhook::TestHelper::DEFAULT_END_TIME_ALT
+ )
+
+ listener = RedmineWebhook::WebhookListener.new
+ payload = listener.send(:build_overtime_payload, time_entry, 'create')
+
+ assert_equal 'overtime_sync', payload[:event]
+ assert_equal 'create', payload[:action]
+ assert_not_nil payload[:timestamp]
+ assert_not_nil payload[:time_entry]
+
+ te = payload[:time_entry]
+ assert_equal time_entry.id, te[:id]
+ assert_equal 2.5, te[:hours]
+ assert_not_nil te[:user]
+ assert_not_nil te[:project]
+ assert_not_nil te[:activity]
+ assert_not_nil te[:custom_field_values]
+ end
+
+ test "should include correct signature in webhook headers" do
+ time_entry = create_overtime_time_entry
+
+ listener = RedmineWebhook::WebhookListener.new
+ payload = listener.send(:build_overtime_payload, time_entry, 'create')
+ payload_string = payload.to_json
+ signature = listener.send(:generate_signature, payload_string)
+
+ assert verify_webhook_signature(signature, payload_string, @webhook_secret)
+ end
+
+ # ============================================
+ # ERROR HANDLING TESTS
+ # ============================================
+
+ test "should handle webhook URL connection errors gracefully" do
+ time_entry = create_overtime_time_entry
+
+ # Use invalid URL
+ setup_plugin_settings(
+ enabled: true,
+ webhook_url: 'http://invalid-domain-that-does-not-exist-12345.com/webhook',
+ webhook_secret: @webhook_secret
+ )
+
+ listener = RedmineWebhook::WebhookListener.new
+
+ # Should not raise error
+ assert_nothing_raised do
+ listener.controller_timelog_edit_after_save(time_entry: time_entry)
+ wait_for_webhook
+ end
+ end
+
+ test "should handle webhook server errors gracefully" do
+ time_entry = create_overtime_time_entry
+
+ stub_webhook_request(
+ @webhook_url,
+ response_code: RedmineWebhook::HttpStatus::INTERNAL_SERVER_ERROR,
+ response_body: RedmineWebhook::HttpResponseBody::INTERNAL_SERVER_ERROR
+ )
+
+ listener = RedmineWebhook::WebhookListener.new
+
+ assert_nothing_raised do
+ listener.controller_timelog_edit_after_save(time_entry: time_entry)
+ wait_for_webhook
+ end
+ end
+
+ # ============================================
+ # VALIDATION FLOW TESTS
+ # ============================================
+
+ test "should not send webhook for invalid payload" do
+ time_entry = create_overtime_time_entry
+ time_entry.update_column(:hours, 0)
+ time_entry.reload
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ listener = RedmineWebhook::WebhookListener.new
+ listener.controller_timelog_edit_after_save(time_entry: time_entry)
+
+ wait_for_webhook
+
+ # Webhook should not be sent for invalid payload
+ assert true
+ end
+
+ test "should not send webhook when plugin is disabled" do
+ disable_plugin
+ time_entry = create_overtime_time_entry
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ listener = RedmineWebhook::WebhookListener.new
+ listener.controller_timelog_edit_after_save(time_entry: time_entry)
+
+ wait_for_webhook
+
+ # Webhook should not be sent
+ assert true
+ end
+
+ test "should not send webhook for non-overtime activity" do
+ regular_activity = TimeEntryActivity.find_or_create_by!(name: 'Development') do |a|
+ a.position = 1
+ end
+
+ time_entry = TimeEntry.create!(
+ project: @project,
+ issue: @issue,
+ user: @user,
+ activity: regular_activity,
+ hours: 2.0,
+ spent_on: Date.today
+ )
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ listener = RedmineWebhook::WebhookListener.new
+ listener.controller_timelog_edit_after_save(time_entry: time_entry)
+
+ wait_for_webhook
+
+ # Webhook should not be sent
+ assert true
+ end
+
+ # ============================================
+ # MULTIPLE ENTRY POINTS TESTS
+ # ============================================
+
+ test "should handle webhook from multiple entry points" do
+ time_entry = create_overtime_time_entry
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ listener = RedmineWebhook::WebhookListener.new
+
+ # Simulate webhook from different entry points
+ listener.controller_timelog_edit_after_save(time_entry: time_entry)
+ listener.controller_issues_edit_after_save(time_entry: time_entry)
+
+ wait_for_webhook(timeout: 3)
+
+ assert true
+ end
+end
diff --git a/test/support/factories.rb b/test/support/factories.rb
new file mode 100644
index 0000000..2511f34
--- /dev/null
+++ b/test/support/factories.rb
@@ -0,0 +1,85 @@
+module RedmineWebhook
+ module Factories
+ # Factory methods for creating test data
+ # These are convenience methods that wrap the TestHelper methods
+
+ def self.create_user(attributes = {})
+ User.find_or_create_by!(login: attributes[:login] || "user_#{SecureRandom.hex(4)}") do |u|
+ u.firstname = attributes[:firstname] || RedmineWebhook::TestHelper::DEFAULT_USER_FIRSTNAME
+ u.lastname = attributes[:lastname] || RedmineWebhook::TestHelper::DEFAULT_USER_LASTNAME
+ u.mail = attributes[:mail] || "test_#{SecureRandom.hex(4)}@example.com"
+ u.password = attributes[:password] || RedmineWebhook::TestHelper::DEFAULT_USER_PASSWORD
+ u.password_confirmation = attributes[:password] || RedmineWebhook::TestHelper::DEFAULT_USER_PASSWORD
+ u.status = User::STATUS_ACTIVE
+ end
+ end
+
+ def self.create_project(attributes = {})
+ identifier = attributes[:identifier] || "project_#{SecureRandom.hex(4)}"
+ Project.find_or_create_by!(identifier: identifier) do |p|
+ p.name = attributes[:name] || RedmineWebhook::TestHelper::DEFAULT_PROJECT_NAME
+ p.enabled_module_names = ['time_tracking']
+ end
+ end
+
+ def self.create_issue(project, attributes = {})
+ Issue.create!(
+ project: project,
+ subject: attributes[:subject] || 'Test Issue',
+ tracker: project.trackers.first || Tracker.first || Tracker.create!(name: 'Bug'),
+ author: attributes[:author] || User.first || create_user,
+ status: IssueStatus.first || IssueStatus.create!(name: 'New')
+ )
+ end
+
+ def self.create_overtime_activity
+ TimeEntryActivity.find_or_create_by!(name: 'Overtime') do |a|
+ a.position = 1
+ a.is_default = false
+ end
+ end
+
+ def self.create_custom_fields
+ start_time = CustomField.find_or_create_by!(name: 'Start time', type: 'TimeEntryCustomField') do |cf|
+ cf.field_format = 'string'
+ cf.is_required = false
+ end
+
+ end_time = CustomField.find_or_create_by!(name: 'End time', type: 'TimeEntryCustomField') do |cf|
+ cf.field_format = 'string'
+ cf.is_required = false
+ end
+
+ [start_time, end_time]
+ end
+
+ def self.create_time_entry(attributes = {})
+ user = attributes[:user] || create_user
+ project = attributes[:project] || create_project
+ issue = attributes[:issue] || create_issue(project)
+ activity = attributes[:activity] || create_overtime_activity
+
+ start_time_field, end_time_field = create_custom_fields
+
+ time_entry = TimeEntry.new(
+ project: project,
+ issue: issue,
+ user: user,
+ activity: activity,
+ hours: attributes[:hours] || 2.0,
+ spent_on: attributes[:spent_on] || Date.today,
+ comments: attributes[:comments] || 'Test time entry'
+ )
+
+ if attributes[:overtime] != false
+ time_entry.custom_field_values = {
+ start_time_field.id.to_s => attributes[:start_time] || RedmineWebhook::TestHelper::DEFAULT_START_TIME,
+ end_time_field.id.to_s => attributes[:end_time] || RedmineWebhook::TestHelper::DEFAULT_END_TIME
+ }
+ end
+
+ time_entry.save!
+ time_entry
+ end
+ end
+end
diff --git a/test/support/webhook_mock.rb b/test/support/webhook_mock.rb
new file mode 100644
index 0000000..8e03226
--- /dev/null
+++ b/test/support/webhook_mock.rb
@@ -0,0 +1,71 @@
+require 'net/http'
+require 'uri'
+require 'json'
+
+module RedmineWebhook
+ class WebhookMock
+ attr_reader :requests, :url
+
+ def initialize(url = RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_URL)
+ @url = url
+ @requests = []
+ @response_code = RedmineWebhook::HttpStatus::OK
+ @response_body = RedmineWebhook::HttpResponseBody::OK
+ end
+
+ def stub_response(code: RedmineWebhook::HttpStatus::OK, body: RedmineWebhook::HttpResponseBody::OK)
+ @response_code = code
+ @response_body = body
+ end
+
+ def last_request
+ @requests.last
+ end
+
+ def request_count
+ @requests.size
+ end
+
+ def clear_requests
+ @requests.clear
+ end
+
+ def capture_request(request)
+ @requests << {
+ method: request.method,
+ uri: request.uri.to_s,
+ headers: request.to_hash,
+ body: request.body
+ }
+ end
+
+ def verify_signature(request, secret)
+ signature = request['X-Webhook-Signature']
+ body = request.body || ''
+ expected = OpenSSL::HMAC.hexdigest('SHA256', secret, body)
+ ActiveSupport::SecurityUtils.secure_compare(signature, expected)
+ end
+
+ def parse_payload(request)
+ JSON.parse(request.body) rescue {}
+ end
+
+ # Mock HTTP response for testing
+ def self.mock_http_post(url, &block)
+ uri = URI.parse(url)
+
+ # Create a mock response
+ response = Net::HTTPResponse.new('1.1', RedmineWebhook::HttpStatus::OK.to_s, RedmineWebhook::HttpResponseBody::OK)
+ response.body = RedmineWebhook::HttpResponseBody::OK
+
+ # If block given, yield the request details
+ if block_given?
+ request = Net::HTTP::Post.new(uri.request_uri)
+ request['Content-Type'] = 'application/json'
+ block.call(request, response)
+ end
+
+ response
+ end
+ end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 54685d3..ba0963c 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -1,2 +1,364 @@
# Load the Redmine helper
require File.expand_path(File.dirname(__FILE__) + '/../../../test/test_helper')
+require 'securerandom'
+
+# Load plugin files
+require File.expand_path(File.dirname(__FILE__) + '/../lib/redmine_webhook')
+require File.expand_path(File.dirname(__FILE__) + '/../lib/redmine_webhook/time_entry_patch')
+
+# Apply TimeEntry patch
+TimeEntry.include(RedmineWebhook::TimeEntryPatch) unless TimeEntry.included_modules.include?(RedmineWebhook::TimeEntryPatch)
+
+# Load support files
+Dir[File.expand_path(File.dirname(__FILE__) + '/support/**/*.rb')].each { |f| require f }
+
+# HTTP Mocking using WebMock (if available) or manual stubbing
+begin
+ require 'webmock/minitest'
+ WebMock.disable_net_connect!(allow_localhost: true)
+rescue LoadError
+ # WebMock not available, will use manual stubbing
+ puts "WebMock not available, using manual HTTP stubbing"
+end
+
+module RedmineWebhook
+ # HTTP Status Code Constants
+ module HttpStatus
+ # Success
+ OK = 200
+ CREATED = 201
+ ACCEPTED = 202
+ NO_CONTENT = 204
+
+ # Client Error
+ BAD_REQUEST = 400
+ UNAUTHORIZED = 401
+ FORBIDDEN = 403
+ NOT_FOUND = 404
+ UNPROCESSABLE_ENTITY = 422
+
+ # Server Error
+ INTERNAL_SERVER_ERROR = 500
+ BAD_GATEWAY = 502
+ SERVICE_UNAVAILABLE = 503
+ end
+
+ # HTTP Response Body Constants (corresponding to status codes)
+ module HttpResponseBody
+ # Success
+ OK = 'OK'
+ CREATED = 'Created'
+ ACCEPTED = 'Accepted'
+ NO_CONTENT = ''
+
+ # Client Error
+ BAD_REQUEST = 'Bad Request'
+ UNAUTHORIZED = 'Unauthorized'
+ FORBIDDEN = 'Forbidden'
+ NOT_FOUND = 'Not Found'
+ UNPROCESSABLE_ENTITY = 'Unprocessable Entity'
+
+ # Server Error
+ INTERNAL_SERVER_ERROR = 'Internal Server Error'
+ BAD_GATEWAY = 'Bad Gateway'
+ SERVICE_UNAVAILABLE = 'Service Unavailable'
+ end
+
+ module TestHelper
+ # ============================================
+ # TEST CONSTANTS
+ # ============================================
+
+ DEFAULT_BASE_URL = 'http://example.com'
+ DEFAULT_WEBHOOK_URL = 'http://example.com/webhook'
+ DEFAULT_WEBHOOK_URL_HTTPS = 'https://example.com/webhook'
+ DEFAULT_WEBHOOK_SECRET = 'test_secret'
+ DEFAULT_FALLBACK_SECRET = 'one_webhook_secret_key_2026'
+ DEFAULT_USER_FIRSTNAME = 'Test'
+ DEFAULT_USER_LASTNAME = 'User'
+ DEFAULT_USER_EMAIL = 'test@example.com'
+ DEFAULT_USER_PASSWORD = 'password123'
+ DEFAULT_PROJECT_IDENTIFIER = 'test-project'
+ DEFAULT_PROJECT_NAME = 'Test Project'
+ DEFAULT_START_TIME = '17:00'
+ DEFAULT_END_TIME = '19:00'
+ DEFAULT_END_TIME_ALT = '19:30'
+
+ # ============================================
+ # PLUGIN SETTINGS HELPERS
+ # ============================================
+
+ def setup_plugin_settings(enabled: true, webhook_url: DEFAULT_WEBHOOK_URL, webhook_secret: DEFAULT_WEBHOOK_SECRET)
+ Setting.plugin_redmine_one_webhook = {
+ 'enabled' => enabled ? '1' : '0',
+ 'webhook_url' => webhook_url,
+ 'webhook_secret' => webhook_secret
+ }
+ end
+
+ def disable_plugin
+ setup_plugin_settings(enabled: false)
+ end
+
+ def clear_plugin_settings
+ Setting.plugin_redmine_one_webhook = {}
+ end
+
+ # ============================================
+ # FACTORY METHODS
+ # ============================================
+
+ def create_test_user(attributes = {})
+ login = attributes[:login] || "testuser_#{SecureRandom.hex(4)}"
+
+ # Check if user exists by login first (login takes precedence)
+ user = nil
+ if attributes[:login]
+ user = User.find_by(login: login)
+ end
+
+ # Only check by email if no login was provided AND email was provided
+ if user.nil? && !attributes[:login] && attributes[:mail] && !attributes[:mail].blank?
+ user = User.find_by(mail: attributes[:mail])
+ end
+
+ # If user exists, update and return it
+ if user
+ # Update existing user to ensure it's valid
+ user.firstname = attributes[:firstname] || DEFAULT_USER_FIRSTNAME
+ user.lastname = attributes[:lastname] || DEFAULT_USER_LASTNAME
+ user.password = attributes[:password] || DEFAULT_USER_PASSWORD
+ user.password_confirmation = attributes[:password] || DEFAULT_USER_PASSWORD
+ user.status = User::STATUS_ACTIVE
+ user.save!
+ return user
+ end
+
+ # Create new user
+ user = User.new(login: login)
+ user.firstname = attributes[:firstname] || DEFAULT_USER_FIRSTNAME
+ user.lastname = attributes[:lastname] || DEFAULT_USER_LASTNAME
+
+ # Handle email: if explicitly set to empty/nil, use nil; otherwise use unique email
+ if attributes.key?(:mail)
+ if attributes[:mail].blank?
+ user.mail = nil
+ elsif attributes[:mail] == DEFAULT_USER_EMAIL && login.start_with?('user_no_email_')
+ # Make DEFAULT_USER_EMAIL unique for auto-generated temporary users to avoid collisions
+ user.mail = "#{login.gsub(/[^a-zA-Z0-9]/, '_')}@example.com"
+ else
+ user.mail = attributes[:mail]
+ end
+ else
+ user.mail = "testuser_#{SecureRandom.hex(4)}@example.com"
+ end
+
+ user.password = attributes[:password] || DEFAULT_USER_PASSWORD
+ user.password_confirmation = attributes[:password] || DEFAULT_USER_PASSWORD
+ user.status = User::STATUS_ACTIVE
+
+ # Save user - skip email validation only if mail is explicitly nil
+ if user.mail.nil? && attributes.key?(:mail)
+ # For test cases where we explicitly want no email, skip email validation
+ user.save(validate: false)
+ else
+ user.save!
+ end
+
+ # Return a fresh user from database to ensure it's valid and persisted
+ user.persisted? ? User.find(user.id) : user
+ end
+
+ def create_test_project(attributes = {})
+ Project.find_or_create_by!(identifier: attributes[:identifier] || DEFAULT_PROJECT_IDENTIFIER) do |p|
+ p.name = attributes[:name] || DEFAULT_PROJECT_NAME
+ p.enabled_module_names = ['time_tracking']
+ end
+ end
+
+ def create_test_issue(project, attributes = {})
+ author = attributes[:author] || User.first || create_test_user
+ Issue.create!(
+ project: project,
+ subject: attributes[:subject] || 'Test Issue',
+ tracker: project.trackers.first || Tracker.first || Tracker.create!(name: 'Bug'),
+ author: author,
+ status: IssueStatus.first || IssueStatus.create!(name: 'New')
+ )
+ end
+
+ def create_overtime_activity
+ TimeEntryActivity.find_or_create_by!(name: 'Overtime') do |a|
+ a.position = 1
+ a.is_default = false
+ end
+ end
+
+ def create_custom_fields_for_time_entry
+ start_time_field = CustomField.find_or_create_by!(name: 'Start time', type: 'TimeEntryCustomField') do |cf|
+ cf.field_format = 'string'
+ cf.is_required = false
+ end
+
+ end_time_field = CustomField.find_or_create_by!(name: 'End time', type: 'TimeEntryCustomField') do |cf|
+ cf.field_format = 'string'
+ cf.is_required = false
+ end
+
+ [start_time_field, end_time_field]
+ end
+
+ def create_overtime_time_entry(attributes = {})
+ user = attributes[:user] || create_test_user
+
+ project = attributes[:project] || create_test_project
+
+ # Add user as project member to allow time entry logging
+ unless project.users.include?(user)
+ role = Role.first || Role.create!(name: 'Test Role', permissions: [:log_time, :view_time_entries])
+ Member.create!(user: user, project: project, roles: [role])
+ end
+
+ issue = attributes[:issue] || create_test_issue(project, author: user)
+ activity = attributes[:activity] || create_overtime_activity
+
+ start_time_field, end_time_field = create_custom_fields_for_time_entry
+
+ time_entry = TimeEntry.new(
+ project: project,
+ issue: issue,
+ user: user,
+ activity: activity,
+ hours: attributes[:hours] || 2.0,
+ spent_on: attributes[:spent_on] || Date.today,
+ comments: attributes[:comments] || 'Test overtime entry'
+ )
+
+ # Set custom field values
+ time_entry.custom_field_values = {
+ start_time_field.id.to_s => attributes[:start_time] || DEFAULT_START_TIME,
+ end_time_field.id.to_s => attributes[:end_time] || DEFAULT_END_TIME
+ }
+
+ time_entry.save!
+ time_entry
+ end
+
+ # ============================================
+ # WEBHOOK SIGNATURE HELPERS
+ # ============================================
+
+ def generate_webhook_signature(payload_string, secret = DEFAULT_WEBHOOK_SECRET)
+ require 'openssl'
+ OpenSSL::HMAC.hexdigest('SHA256', secret, payload_string)
+ end
+
+ def verify_webhook_signature(signature, payload_string, secret = DEFAULT_WEBHOOK_SECRET)
+ expected_signature = generate_webhook_signature(payload_string, secret)
+ ActiveSupport::SecurityUtils.secure_compare(signature, expected_signature)
+ end
+
+ # ============================================
+ # HTTP MOCKING HELPERS
+ # ============================================
+
+ def stub_webhook_request(url, response_body: HttpResponseBody::OK, response_code: HttpStatus::OK, &block)
+ if defined?(WebMock)
+ WebMock.stub_request(:post, url).to_return(
+ status: response_code,
+ body: response_body,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ else
+ # Manual stubbing using Net::HTTP
+ uri = URI.parse(url)
+ allow(Net::HTTP).to receive(:new).and_return(mock_http_response(response_code, response_body)) if defined?(RSpec)
+ # For Minitest, we'll need to use a different approach
+ yield if block_given?
+ end
+ end
+
+ def mock_http_response(code, body)
+ response = double('HTTPResponse')
+ allow(response).to receive(:code).and_return(code.to_s)
+ allow(response).to receive(:body).and_return(body)
+ http = double('Net::HTTP')
+ allow(http).to receive(:use_ssl=)
+ allow(http).to receive(:open_timeout=)
+ allow(http).to receive(:read_timeout=)
+ allow(http).to receive(:request).and_return(response)
+ http
+ end
+
+ # ============================================
+ # WAIT HELPERS (for async webhook sending)
+ # ============================================
+
+ def wait_for_webhook(timeout: 2)
+ sleep(timeout)
+ end
+
+ # ============================================
+ # PAYLOAD HELPERS
+ # ============================================
+
+ def expected_webhook_payload(time_entry, action: 'create')
+ {
+ event: 'overtime_sync',
+ action: action,
+ timestamp: anything,
+ time_entry: {
+ id: time_entry.id,
+ hours: time_entry.hours,
+ comments: time_entry.comments,
+ spent_on: time_entry.spent_on.to_s,
+ created_on: time_entry.created_on.iso8601,
+ updated_on: time_entry.updated_on.iso8601,
+ activity: {
+ id: time_entry.activity.id,
+ name: time_entry.activity.name
+ },
+ user: {
+ id: time_entry.user.id,
+ login: time_entry.user.login,
+ firstname: time_entry.user.firstname,
+ lastname: time_entry.user.lastname,
+ mail: time_entry.user.mail
+ },
+ project: {
+ id: time_entry.project.id,
+ identifier: time_entry.project.identifier,
+ name: time_entry.project.name
+ },
+ issue: time_entry.issue ? {
+ id: time_entry.issue.id,
+ subject: time_entry.issue.subject,
+ tracker: time_entry.issue.tracker&.name
+ } : nil,
+ custom_field_values: array_including(
+ hash_including(custom_field_name: 'Start time'),
+ hash_including(custom_field_name: 'End time')
+ )
+ }
+ }
+ end
+
+ # Helper for matching "anything" in assertions
+ def anything
+ Object.new.tap { |o| def o.===(other); true; end }
+ end
+
+ def array_including(*items)
+ items
+ end
+
+ def hash_including(**keys)
+ keys
+ end
+ end
+end
+
+# Include helper in all tests
+class ActiveSupport::TestCase
+ include RedmineWebhook::TestHelper
+end
diff --git a/test/unit/settings_test.rb b/test/unit/settings_test.rb
new file mode 100644
index 0000000..906239a
--- /dev/null
+++ b/test/unit/settings_test.rb
@@ -0,0 +1,203 @@
+require File.expand_path('../../test_helper', __FILE__)
+
+class SettingsTest < ActiveSupport::TestCase
+ def setup
+ clear_plugin_settings
+ end
+
+ def teardown
+ clear_plugin_settings
+ end
+
+ # ============================================
+ # DEFAULT SETTINGS TESTS
+ # ============================================
+
+ test "should have default settings" do
+ # Defaults are set in init.rb
+ settings = Setting.plugin_redmine_one_webhook
+
+ # After plugin registration, defaults should be available
+ assert_not_nil settings
+ end
+
+ test "should have default webhook secret" do
+ setup_plugin_settings(enabled: true, webhook_url: 'http://test.com', webhook_secret: '')
+
+ listener = RedmineWebhook::WebhookListener.new
+ secret = listener.send(:webhook_secret)
+
+ assert_equal RedmineWebhook::TestHelper::DEFAULT_FALLBACK_SECRET, secret
+ end
+
+ # ============================================
+ # SETTINGS VALIDATION TESTS
+ # ============================================
+
+ test "should accept valid webhook URL" do
+ setup_plugin_settings(
+ enabled: true,
+ webhook_url: RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_URL,
+ webhook_secret: RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_SECRET
+ )
+
+ listener = RedmineWebhook::WebhookListener.new
+ url = listener.send(:global_webhook_url)
+
+ assert_equal RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_URL, url
+ end
+
+ test "should accept HTTPS webhook URL" do
+ setup_plugin_settings(
+ enabled: true,
+ webhook_url: RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_URL_HTTPS,
+ webhook_secret: RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_SECRET
+ )
+
+ listener = RedmineWebhook::WebhookListener.new
+ url = listener.send(:global_webhook_url)
+
+ assert_equal RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_URL_HTTPS, url
+ end
+
+ test "should handle empty webhook URL" do
+ setup_plugin_settings(
+ enabled: true,
+ webhook_url: '',
+ webhook_secret: RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_SECRET
+ )
+
+ listener = RedmineWebhook::WebhookListener.new
+ url = listener.send(:global_webhook_url)
+
+ assert_equal '', url
+ end
+
+ test "should handle whitespace in webhook URL" do
+ setup_plugin_settings(
+ enabled: true,
+ webhook_url: " #{RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_URL} ",
+ webhook_secret: RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_SECRET
+ )
+
+ listener = RedmineWebhook::WebhookListener.new
+ url = listener.send(:global_webhook_url)
+
+ assert_equal RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_URL, url
+ end
+
+ # ============================================
+ # ENABLED/DISABLED TESTS
+ # ============================================
+
+ test "should check if plugin is enabled" do
+ setup_plugin_settings(enabled: true)
+
+ listener = RedmineWebhook::WebhookListener.new
+ assert listener.send(:plugin_enabled?)
+ end
+
+ test "should check if plugin is disabled" do
+ setup_plugin_settings(enabled: false)
+
+ listener = RedmineWebhook::WebhookListener.new
+ assert_not listener.send(:plugin_enabled?)
+ end
+
+ test "should treat missing enabled setting as disabled" do
+ Setting.plugin_redmine_one_webhook = {
+ 'webhook_url' => RedmineWebhook::TestHelper::DEFAULT_BASE_URL,
+ 'webhook_secret' => 'secret'
+ }
+
+ listener = RedmineWebhook::WebhookListener.new
+ assert_not listener.send(:plugin_enabled?)
+ end
+
+ # ============================================
+ # SECRET KEY TESTS
+ # ============================================
+
+ test "should use configured secret key" do
+ custom_secret = 'my_custom_secret_123'
+ setup_plugin_settings(
+ enabled: true,
+ webhook_url: RedmineWebhook::TestHelper::DEFAULT_BASE_URL,
+ webhook_secret: custom_secret
+ )
+
+ listener = RedmineWebhook::WebhookListener.new
+ secret = listener.send(:webhook_secret)
+
+ assert_equal custom_secret, secret
+ end
+
+ test "should use default secret when not configured" do
+ setup_plugin_settings(
+ enabled: true,
+ webhook_url: RedmineWebhook::TestHelper::DEFAULT_BASE_URL,
+ webhook_secret: ''
+ )
+
+ listener = RedmineWebhook::WebhookListener.new
+ secret = listener.send(:webhook_secret)
+
+ assert_equal RedmineWebhook::TestHelper::DEFAULT_FALLBACK_SECRET, secret
+ end
+
+ test "should handle whitespace in secret key" do
+ setup_plugin_settings(
+ enabled: true,
+ webhook_url: RedmineWebhook::TestHelper::DEFAULT_BASE_URL,
+ webhook_secret: " #{RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_SECRET} "
+ )
+
+ listener = RedmineWebhook::WebhookListener.new
+ secret = listener.send(:webhook_secret)
+
+ assert_equal RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_SECRET, secret
+ end
+
+ # ============================================
+ # SETTINGS PERSISTENCE TESTS
+ # ============================================
+
+ test "should persist settings" do
+ setup_plugin_settings(
+ enabled: true,
+ webhook_url: 'http://test.com/webhook',
+ webhook_secret: 'persisted_secret'
+ )
+
+ # Reload settings
+ settings = Setting.plugin_redmine_one_webhook
+
+ assert_equal '1', settings['enabled']
+ assert_equal 'http://test.com/webhook', settings['webhook_url']
+ assert_equal 'persisted_secret', settings['webhook_secret']
+ end
+
+ # ============================================
+ # EDGE CASES
+ # ============================================
+
+ test "should handle nil settings gracefully" do
+ Setting.plugin_redmine_one_webhook = nil
+
+ listener = RedmineWebhook::WebhookListener.new
+
+ assert_not listener.send(:plugin_enabled?)
+ assert_equal '', listener.send(:global_webhook_url)
+ assert_equal RedmineWebhook::TestHelper::DEFAULT_FALLBACK_SECRET, listener.send(:webhook_secret)
+ end
+
+ test "should handle missing settings keys" do
+ Setting.plugin_redmine_one_webhook = {}
+
+ listener = RedmineWebhook::WebhookListener.new
+
+ assert_not listener.send(:plugin_enabled?)
+ assert_equal '', listener.send(:global_webhook_url)
+ assert_equal RedmineWebhook::TestHelper::DEFAULT_FALLBACK_SECRET, listener.send(:webhook_secret)
+ end
+end
diff --git a/test/unit/time_entry_patch_test.rb b/test/unit/time_entry_patch_test.rb
new file mode 100644
index 0000000..2896bab
--- /dev/null
+++ b/test/unit/time_entry_patch_test.rb
@@ -0,0 +1,259 @@
+require File.expand_path('../../test_helper', __FILE__)
+
+class TimeEntryPatchTest < ActiveSupport::TestCase
+ def setup
+ @webhook_url = RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_URL
+ @webhook_secret = RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_SECRET
+ setup_plugin_settings(
+ enabled: true,
+ webhook_url: @webhook_url,
+ webhook_secret: @webhook_secret
+ )
+
+ @user = create_test_user
+ @project = create_test_project
+
+ # Add user as project member
+ role = Role.first || Role.create!(name: 'Test Role', permissions: [:log_time, :view_time_entries])
+ Member.create!(user: @user, project: @project, roles: [role]) unless @project.users.include?(@user)
+
+ @issue = create_test_issue(@project)
+ @overtime_activity = create_overtime_activity
+ @start_field, @end_field = create_custom_fields_for_time_entry
+ end
+
+ def teardown
+ clear_plugin_settings
+ end
+
+ # ============================================
+ # BEFORE_DESTROY CALLBACK TESTS
+ # ============================================
+
+ test "should send webhook when overtime time entry is deleted" do
+ time_entry = create_overtime_time_entry
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ assert_nothing_raised do
+ time_entry.destroy
+ wait_for_webhook
+ end
+ end
+
+ test "should not send webhook when non-overtime time entry is deleted" do
+ regular_activity = TimeEntryActivity.find_or_create_by!(name: 'Development') do |a|
+ a.position = 1
+ end
+
+ time_entry = TimeEntry.create!(
+ project: @project,
+ issue: @issue,
+ user: @user,
+ activity: regular_activity,
+ hours: 2.0,
+ spent_on: Date.today
+ )
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ # Should not raise error, but webhook should not be sent
+ assert_nothing_raised do
+ time_entry.destroy
+ wait_for_webhook
+ end
+ end
+
+ test "should not send webhook when plugin is disabled" do
+ disable_plugin
+ time_entry = create_overtime_time_entry
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ assert_nothing_raised do
+ time_entry.destroy
+ wait_for_webhook
+ end
+ end
+
+ test "should not send webhook when URL is blank" do
+ setup_plugin_settings(enabled: true, webhook_url: '', webhook_secret: @webhook_secret)
+ time_entry = create_overtime_time_entry
+
+ assert_nothing_raised do
+ time_entry.destroy
+ wait_for_webhook
+ end
+ end
+
+ # ============================================
+ # OVERTIME ACTIVITY DETECTION TESTS
+ # ============================================
+
+ test "should detect overtime activity" do
+ time_entry = create_overtime_time_entry
+
+ assert time_entry.send(:overtime_activity?)
+ end
+
+ test "should detect OT activity (case insensitive)" do
+ ot_activity = TimeEntryActivity.find_or_create_by!(name: 'OT') do |a|
+ a.position = 1
+ end
+
+ time_entry = create_overtime_time_entry(activity: ot_activity)
+
+ assert time_entry.send(:overtime_activity?)
+ end
+
+ test "should not detect non-overtime activity" do
+ regular_activity = TimeEntryActivity.find_or_create_by!(name: 'Development') do |a|
+ a.position = 1
+ end
+
+ time_entry = TimeEntry.create!(
+ project: @project,
+ issue: @issue,
+ user: @user,
+ activity: regular_activity,
+ hours: 2.0,
+ spent_on: Date.today
+ )
+
+ assert_not time_entry.send(:overtime_activity?)
+ end
+
+ # ============================================
+ # DELETE PAYLOAD TESTS
+ # ============================================
+
+ test "should build correct delete payload" do
+ time_entry = create_overtime_time_entry
+ payload = time_entry.send(:build_delete_payload)
+
+ assert_equal 'overtime_sync', payload[:event]
+ assert_equal 'delete', payload[:action]
+ assert_not_nil payload[:timestamp]
+ assert_not_nil payload[:time_entry]
+ assert_equal time_entry.id, payload[:time_entry][:id]
+ end
+
+ # ============================================
+ # UPDATE_ALL TESTS (Issue Reassignment)
+ # ============================================
+
+ test "should detect issue_id change in update_all with hash" do
+ time_entry = create_overtime_time_entry
+ new_issue = create_test_issue(@project)
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ # This will trigger update_all with hash format
+ assert_nothing_raised do
+ TimeEntry.where(id: time_entry.id).update_all(issue_id: new_issue.id)
+ wait_for_webhook
+ end
+ end
+
+ test "should detect issue_id nullification in update_all" do
+ time_entry = create_overtime_time_entry
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ assert_nothing_raised do
+ TimeEntry.where(id: time_entry.id).update_all(issue_id: nil)
+ wait_for_webhook
+ end
+ end
+
+ test "should handle update_all with string format" do
+ time_entry = create_overtime_time_entry
+ new_issue = create_test_issue(@project)
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ assert_nothing_raised do
+ TimeEntry.where(id: time_entry.id).update_all("issue_id = #{new_issue.id}")
+ wait_for_webhook
+ end
+ end
+
+ test "should not process non-issue_id updates in update_all" do
+ time_entry = create_overtime_time_entry
+
+ # Update something other than issue_id
+ assert_nothing_raised do
+ TimeEntry.where(id: time_entry.id).update_all(comments: 'Updated comment')
+ end
+ end
+
+ # ============================================
+ # PLUGIN SETTINGS TESTS
+ # ============================================
+
+ test "should check if plugin is enabled" do
+ time_entry = create_overtime_time_entry
+
+ assert time_entry.send(:plugin_enabled?)
+ end
+
+ test "should get webhook URL from settings" do
+ time_entry = create_overtime_time_entry
+
+ assert_equal @webhook_url, time_entry.send(:global_webhook_url)
+ end
+
+ test "should get webhook secret from settings" do
+ time_entry = create_overtime_time_entry
+
+ assert_equal @webhook_secret, time_entry.send(:webhook_secret)
+ end
+
+ test "should use default secret when not configured" do
+ setup_plugin_settings(enabled: true, webhook_url: @webhook_url, webhook_secret: '')
+ time_entry = create_overtime_time_entry
+
+ assert_equal RedmineWebhook::TestHelper::DEFAULT_FALLBACK_SECRET, time_entry.send(:webhook_secret)
+ end
+
+ # ============================================
+ # SIGNATURE GENERATION TESTS
+ # ============================================
+
+ test "should generate correct HMAC-SHA256 signature" do
+ time_entry = create_overtime_time_entry
+ payload_string = '{"test": "data"}'
+ signature = time_entry.send(:generate_signature, payload_string)
+
+ expected = generate_webhook_signature(payload_string, @webhook_secret)
+ assert_equal expected, signature
+ end
+
+ # ============================================
+ # EDGE CASES
+ # ============================================
+
+ test "should handle time entry without issue" do
+ time_entry = create_overtime_time_entry
+ time_entry.update_column(:issue_id, nil)
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ assert_nothing_raised do
+ time_entry.destroy
+ wait_for_webhook
+ end
+ end
+
+ test "should handle time entry without activity" do
+ skip "Cannot test nil activity_id - database has NOT NULL constraint"
+ time_entry = create_overtime_time_entry
+ time_entry.update_column(:activity_id, nil)
+
+ # Should not send webhook
+ assert_nothing_raised do
+ time_entry.destroy
+ wait_for_webhook
+ end
+ end
+end
diff --git a/test/unit/webhook_listener_test.rb b/test/unit/webhook_listener_test.rb
new file mode 100644
index 0000000..1d964f8
--- /dev/null
+++ b/test/unit/webhook_listener_test.rb
@@ -0,0 +1,304 @@
+require File.expand_path('../../test_helper', __FILE__)
+
+class WebhookListenerTest < ActiveSupport::TestCase
+ def setup
+ @listener = RedmineWebhook::WebhookListener.new
+ @webhook_url = RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_URL
+ @webhook_secret = RedmineWebhook::TestHelper::DEFAULT_WEBHOOK_SECRET
+ setup_plugin_settings(
+ enabled: true,
+ webhook_url: @webhook_url,
+ webhook_secret: @webhook_secret
+ )
+
+ @user = create_test_user
+ @project = create_test_project
+
+ # Add user as project member
+ role = Role.first || Role.create!(name: 'Test Role', permissions: [:log_time, :view_time_entries])
+ Member.create!(user: @user, project: @project, roles: [role]) unless @project.users.include?(@user)
+
+ @issue = create_test_issue(@project)
+ @overtime_activity = create_overtime_activity
+ @start_field, @end_field = create_custom_fields_for_time_entry
+ end
+
+ def teardown
+ clear_plugin_settings
+ end
+
+ # ============================================
+ # PLUGIN ENABLED/DISABLED TESTS
+ # ============================================
+
+ test "should not send webhook when plugin is disabled" do
+ disable_plugin
+ time_entry = create_overtime_time_entry
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ @listener.controller_timelog_edit_before_save(time_entry: time_entry)
+ wait_for_webhook
+
+ # Webhook should not be sent when plugin is disabled
+ assert true
+ end
+
+ test "should send webhook when plugin is enabled" do
+ time_entry = create_overtime_time_entry
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ @listener.controller_timelog_edit_before_save(time_entry: time_entry)
+ wait_for_webhook
+
+ # Webhook should be sent (we can't easily verify without WebMock, but we can check logs)
+ assert true # Placeholder - in real test with WebMock, we'd verify the request
+ end
+
+ # ============================================
+ # OVERTIME ACTIVITY DETECTION TESTS
+ # ============================================
+
+ test "should detect overtime activity" do
+ time_entry = create_overtime_time_entry(activity: @overtime_activity)
+
+ assert @listener.send(:overtime_activity?, time_entry)
+ end
+
+ test "should detect OT activity (case insensitive)" do
+ ot_activity = TimeEntryActivity.find_or_create_by!(name: 'OT') do |a|
+ a.position = 1
+ end
+
+ time_entry = create_overtime_time_entry(activity: ot_activity)
+
+ assert @listener.send(:overtime_activity?, time_entry)
+ end
+
+ test "should not detect non-overtime activity" do
+ regular_activity = TimeEntryActivity.find_or_create_by!(name: 'Development') do |a|
+ a.position = 1
+ end
+
+ time_entry = TimeEntry.create!(
+ project: @project,
+ issue: @issue,
+ user: @user,
+ activity: regular_activity,
+ hours: 2.0,
+ spent_on: Date.today
+ )
+
+ assert_not @listener.send(:overtime_activity?, time_entry)
+ end
+
+ # ============================================
+ # VALIDATION TESTS
+ # ============================================
+
+ test "should validate overtime payload with all required fields" do
+ time_entry = create_overtime_time_entry(
+ hours: 2.5,
+ start_time: RedmineWebhook::TestHelper::DEFAULT_START_TIME,
+ end_time: RedmineWebhook::TestHelper::DEFAULT_END_TIME
+ )
+
+ assert @listener.send(:valid_overtime_payload?, time_entry)
+ end
+
+ test "should reject payload with zero hours" do
+ time_entry = create_overtime_time_entry(hours: 0)
+
+ assert_not @listener.send(:valid_overtime_payload?, time_entry)
+ end
+
+ test "should reject payload without start time" do
+ time_entry = create_overtime_time_entry
+ time_entry.custom_field_values = {
+ @start_field.id.to_s => '',
+ @end_field.id.to_s => RedmineWebhook::TestHelper::DEFAULT_END_TIME
+ }
+ time_entry.save!
+
+ assert_not @listener.send(:valid_overtime_payload?, time_entry)
+ end
+
+ test "should reject payload without end time" do
+ time_entry = create_overtime_time_entry
+ time_entry.custom_field_values = {
+ @start_field.id.to_s => RedmineWebhook::TestHelper::DEFAULT_START_TIME,
+ @end_field.id.to_s => ''
+ }
+ time_entry.save!
+
+ assert_not @listener.send(:valid_overtime_payload?, time_entry)
+ end
+
+ test "should reject payload with non-overtime activity" do
+ regular_activity = TimeEntryActivity.find_or_create_by!(name: 'Development') do |a|
+ a.position = 1
+ end
+
+ time_entry = create_overtime_time_entry(activity: regular_activity)
+
+ assert_not @listener.send(:valid_overtime_payload?, time_entry)
+ end
+
+ # ============================================
+ # SIGNATURE GENERATION TESTS
+ # ============================================
+
+ test "should generate correct HMAC-SHA256 signature" do
+ payload_string = '{"test": "data"}'
+ signature = @listener.send(:generate_signature, payload_string)
+
+ expected = generate_webhook_signature(payload_string, @webhook_secret)
+ assert_equal expected, signature
+ end
+
+ test "should use default secret when not configured" do
+ clear_plugin_settings
+ payload_string = '{"test": "data"}'
+ signature = @listener.send(:generate_signature, payload_string)
+
+ expected = generate_webhook_signature(payload_string, RedmineWebhook::TestHelper::DEFAULT_FALLBACK_SECRET)
+ assert_equal expected, signature
+ end
+
+ # ============================================
+ # WEBHOOK URL TESTS
+ # ============================================
+
+ test "should not send webhook when URL is blank" do
+ setup_plugin_settings(enabled: true, webhook_url: '', webhook_secret: @webhook_secret)
+ time_entry = create_overtime_time_entry
+
+ assert_not @listener.send(:should_send_webhook?, time_entry)
+ end
+
+ test "should get webhook URL from settings" do
+ assert_equal @webhook_url, @listener.send(:global_webhook_url)
+ end
+
+ # ============================================
+ # CUSTOM FIELD VALUE TESTS
+ # ============================================
+
+ test "should get custom field value by name" do
+ time_entry = create_overtime_time_entry(
+ start_time: '17:30',
+ end_time: '20:00'
+ )
+
+ start_time = @listener.send(:get_custom_field_value, time_entry, ['start time', 'start_time'])
+ end_time = @listener.send(:get_custom_field_value, time_entry, ['end time', 'end_time'])
+
+ assert_equal '17:30', start_time
+ assert_equal '20:00', end_time
+ end
+
+ test "should handle case-insensitive custom field names" do
+ time_entry = create_overtime_time_entry(
+ start_time: '18:00',
+ end_time: '21:00'
+ )
+
+ # Try different case variations
+ start_time = @listener.send(:get_custom_field_value, time_entry, ['START TIME'])
+ end_time = @listener.send(:get_custom_field_value, time_entry, ['End Time'])
+
+ assert_equal '18:00', start_time
+ assert_equal '21:00', end_time
+ end
+
+ # ============================================
+ # PAYLOAD BUILDING TESTS
+ # ============================================
+
+ test "should build correct create payload" do
+ time_entry = create_overtime_time_entry
+ payload = @listener.send(:build_overtime_payload, time_entry, 'create')
+
+ assert_equal 'overtime_sync', payload[:event]
+ assert_equal 'create', payload[:action]
+ assert_not_nil payload[:timestamp]
+ assert_not_nil payload[:time_entry]
+ assert_equal time_entry.id, payload[:time_entry][:id]
+ end
+
+ test "should build correct update payload" do
+ time_entry = create_overtime_time_entry
+ payload = @listener.send(:build_overtime_payload, time_entry, 'update')
+
+ assert_equal 'update', payload[:action]
+ end
+
+ test "should build correct delete payload" do
+ time_entry = create_overtime_time_entry
+ payload = @listener.send(:build_overtime_payload, time_entry, 'delete')
+
+ assert_equal 'delete', payload[:action]
+ end
+
+ # ============================================
+ # HOOK EXECUTION TESTS
+ # ============================================
+
+ test "controller_timelog_edit_before_save should process new time entry" do
+ time_entry = TimeEntry.new(
+ project: @project,
+ issue: @issue,
+ user: @user,
+ activity: @overtime_activity,
+ hours: 2.0,
+ spent_on: Date.today
+ )
+ time_entry.custom_field_values = {
+ @start_field.id.to_s => RedmineWebhook::TestHelper::DEFAULT_START_TIME,
+ @end_field.id.to_s => RedmineWebhook::TestHelper::DEFAULT_END_TIME
+ }
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ # This should not raise an error
+ assert_nothing_raised do
+ @listener.controller_timelog_edit_before_save(time_entry: time_entry)
+ wait_for_webhook
+ end
+ end
+
+ test "controller_timelog_edit_after_save should process update" do
+ time_entry = create_overtime_time_entry
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ assert_nothing_raised do
+ @listener.controller_timelog_edit_after_save(time_entry: time_entry)
+ wait_for_webhook
+ end
+ end
+
+ test "controller_issues_edit_after_save should process time entry from issue" do
+ time_entry = create_overtime_time_entry
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ assert_nothing_raised do
+ @listener.controller_issues_edit_after_save(time_entry: time_entry)
+ wait_for_webhook
+ end
+ end
+
+ test "controller_timelog_bulk_edit_after_save should process multiple entries" do
+ time_entry1 = create_overtime_time_entry
+ time_entry2 = create_overtime_time_entry
+
+ stub_webhook_request(@webhook_url, response_code: RedmineWebhook::HttpStatus::OK)
+
+ assert_nothing_raised do
+ @listener.controller_timelog_bulk_edit_after_save(time_entries: [time_entry1, time_entry2])
+ wait_for_webhook(timeout: 3)
+ end
+ end
+end
diff --git a/test/unit/wrapper_tests/author_wrapper_test.rb b/test/unit/wrapper_tests/author_wrapper_test.rb
new file mode 100644
index 0000000..cf16daf
--- /dev/null
+++ b/test/unit/wrapper_tests/author_wrapper_test.rb
@@ -0,0 +1,51 @@
+require File.expand_path('../../../test_helper', __FILE__)
+require 'securerandom'
+
+class AuthorWrapperTest < ActiveSupport::TestCase
+ def setup
+ @user = create_test_user(
+ login: 'testuser',
+ firstname: RedmineWebhook::TestHelper::DEFAULT_USER_FIRSTNAME,
+ lastname: RedmineWebhook::TestHelper::DEFAULT_USER_LASTNAME,
+ mail: RedmineWebhook::TestHelper::DEFAULT_USER_EMAIL
+ )
+ end
+
+ test "should convert user to hash" do
+ wrapper = RedmineWebhook::AuthorWrapper.new(@user)
+ hash = wrapper.to_hash
+
+ assert_equal @user.id, hash[:id]
+ assert_equal 'testuser', hash[:login]
+ assert_equal RedmineWebhook::TestHelper::DEFAULT_USER_EMAIL, hash[:mail]
+ assert_equal RedmineWebhook::TestHelper::DEFAULT_USER_FIRSTNAME, hash[:firstname]
+ assert_equal RedmineWebhook::TestHelper::DEFAULT_USER_LASTNAME, hash[:lastname]
+ end
+
+ test "should return nil for nil author" do
+ wrapper = RedmineWebhook::AuthorWrapper.new(nil)
+ hash = wrapper.to_hash
+
+ assert_nil hash
+ end
+
+ test "should include icon_url when user has email" do
+ wrapper = RedmineWebhook::AuthorWrapper.new(@user)
+ hash = wrapper.to_hash
+
+ assert_not_nil hash[:icon_url]
+ end
+
+ test "should handle user without email" do
+ # Create user with email first, then remove it for testing
+ user = create_test_user(login: "user_no_email_#{SecureRandom.hex(4)}", mail: RedmineWebhook::TestHelper::DEFAULT_USER_EMAIL)
+ user.mail = nil
+ user.save(validate: false)
+ user.reload
+
+ wrapper = RedmineWebhook::AuthorWrapper.new(user)
+ hash = wrapper.to_hash
+
+ assert_nil hash[:icon_url]
+ end
+end
diff --git a/test/unit/wrapper_tests/custom_field_value_wrapper_test.rb b/test/unit/wrapper_tests/custom_field_value_wrapper_test.rb
new file mode 100644
index 0000000..08a7fac
--- /dev/null
+++ b/test/unit/wrapper_tests/custom_field_value_wrapper_test.rb
@@ -0,0 +1,32 @@
+require File.expand_path('../../../test_helper', __FILE__)
+
+class CustomFieldValueWrapperTest < ActiveSupport::TestCase
+ def setup
+ @start_field, @end_field = create_custom_fields_for_time_entry
+ @time_entry = create_overtime_time_entry(
+ start_time: RedmineWebhook::TestHelper::DEFAULT_START_TIME,
+ end_time: RedmineWebhook::TestHelper::DEFAULT_END_TIME
+ )
+
+ @custom_field_value = @time_entry.custom_field_values.find { |cfv| cfv.custom_field_id == @start_field.id }
+ end
+
+ test "should convert custom field value to hash" do
+ wrapper = RedmineWebhook::CustomFieldValueWrapper.new(@custom_field_value)
+ hash = wrapper.to_hash
+
+ assert_equal @start_field.id, hash[:custom_field_id]
+ assert_equal 'Start time', hash[:custom_field_name]
+ assert_equal RedmineWebhook::TestHelper::DEFAULT_START_TIME, hash[:value]
+ end
+
+ test "should handle different custom field types" do
+ end_cfv = @time_entry.custom_field_values.find { |cfv| cfv.custom_field_id == @end_field.id }
+ wrapper = RedmineWebhook::CustomFieldValueWrapper.new(end_cfv)
+ hash = wrapper.to_hash
+
+ assert_equal @end_field.id, hash[:custom_field_id]
+ assert_equal 'End time', hash[:custom_field_name]
+ assert_equal RedmineWebhook::TestHelper::DEFAULT_END_TIME, hash[:value]
+ end
+end
diff --git a/test/unit/wrapper_tests/issue_wrapper_test.rb b/test/unit/wrapper_tests/issue_wrapper_test.rb
new file mode 100644
index 0000000..0ccb7d1
--- /dev/null
+++ b/test/unit/wrapper_tests/issue_wrapper_test.rb
@@ -0,0 +1,57 @@
+require File.expand_path('../../../test_helper', __FILE__)
+
+class IssueWrapperTest < ActiveSupport::TestCase
+ def setup
+ @user = create_test_user
+ @project = create_test_project
+ @issue = create_test_issue(@project, subject: 'Test Issue')
+ end
+
+ test "should convert issue to hash" do
+ wrapper = RedmineWebhook::IssueWrapper.new(@issue)
+ hash = wrapper.to_hash
+
+ assert_equal @issue.id, hash[:id]
+ assert_equal 'Test Issue', hash[:subject]
+ assert_equal @issue.description, hash[:description]
+ assert_not_nil hash[:created_on]
+ assert_not_nil hash[:updated_on]
+ end
+
+ test "should include project in hash" do
+ wrapper = RedmineWebhook::IssueWrapper.new(@issue)
+ hash = wrapper.to_hash
+
+ assert_not_nil hash[:project]
+ assert_equal @project.id, hash[:project][:id]
+ end
+
+ test "should include status in hash" do
+ wrapper = RedmineWebhook::IssueWrapper.new(@issue)
+ hash = wrapper.to_hash
+
+ assert_not_nil hash[:status]
+ end
+
+ test "should include tracker in hash" do
+ wrapper = RedmineWebhook::IssueWrapper.new(@issue)
+ hash = wrapper.to_hash
+
+ assert_not_nil hash[:tracker]
+ end
+
+ test "should include author in hash" do
+ wrapper = RedmineWebhook::IssueWrapper.new(@issue)
+ hash = wrapper.to_hash
+
+ assert_not_nil hash[:author]
+ end
+
+ test "should include custom field values" do
+ wrapper = RedmineWebhook::IssueWrapper.new(@issue)
+ hash = wrapper.to_hash
+
+ assert_not_nil hash[:custom_field_values]
+ assert hash[:custom_field_values].is_a?(Array)
+ end
+end
diff --git a/test/unit/wrapper_tests/project_wrapper_test.rb b/test/unit/wrapper_tests/project_wrapper_test.rb
new file mode 100644
index 0000000..a1129bd
--- /dev/null
+++ b/test/unit/wrapper_tests/project_wrapper_test.rb
@@ -0,0 +1,31 @@
+require File.expand_path('../../../test_helper', __FILE__)
+
+class ProjectWrapperTest < ActiveSupport::TestCase
+ def setup
+ @project = create_test_project(
+ identifier: RedmineWebhook::TestHelper::DEFAULT_PROJECT_IDENTIFIER,
+ name: RedmineWebhook::TestHelper::DEFAULT_PROJECT_NAME
+ )
+ end
+
+ test "should convert project to hash" do
+ wrapper = RedmineWebhook::ProjectWrapper.new(@project)
+ hash = wrapper.to_hash
+
+ assert_equal @project.id, hash[:id]
+ assert_equal RedmineWebhook::TestHelper::DEFAULT_PROJECT_IDENTIFIER, hash[:identifier]
+ assert_equal RedmineWebhook::TestHelper::DEFAULT_PROJECT_NAME, hash[:name]
+ assert_equal @project.description, hash[:description]
+ assert_not_nil hash[:created_on]
+ end
+
+ test "should include homepage if present" do
+ @project.update_column(:homepage, RedmineWebhook::TestHelper::DEFAULT_BASE_URL)
+ @project.reload
+
+ wrapper = RedmineWebhook::ProjectWrapper.new(@project)
+ hash = wrapper.to_hash
+
+ assert_equal RedmineWebhook::TestHelper::DEFAULT_BASE_URL, hash[:homepage]
+ end
+end
diff --git a/test/unit/wrapper_tests/time_entry_wrapper_test.rb b/test/unit/wrapper_tests/time_entry_wrapper_test.rb
new file mode 100644
index 0000000..dbe4aab
--- /dev/null
+++ b/test/unit/wrapper_tests/time_entry_wrapper_test.rb
@@ -0,0 +1,118 @@
+require File.expand_path('../../../test_helper', __FILE__)
+
+class TimeEntryWrapperTest < ActiveSupport::TestCase
+ def setup
+ @user = create_test_user
+ @project = create_test_project
+ @issue = create_test_issue(@project)
+ @activity = create_overtime_activity
+ @start_field, @end_field = create_custom_fields_for_time_entry
+
+ @time_entry = create_overtime_time_entry(
+ user: @user,
+ project: @project,
+ issue: @issue,
+ activity: @activity,
+ hours: 2.5,
+ start_time: RedmineWebhook::TestHelper::DEFAULT_START_TIME,
+ end_time: RedmineWebhook::TestHelper::DEFAULT_END_TIME
+ )
+
+ @wrapper = RedmineWebhook::TimeEntryWrapper.new(@time_entry)
+ end
+
+ test "should convert time entry to hash" do
+ hash = @wrapper.to_hash
+
+ assert_equal @time_entry.id, hash[:id]
+ assert_equal 2.5, hash[:hours]
+ assert_equal @time_entry.comments, hash[:comments]
+ assert_equal @time_entry.spent_on, hash[:spent_on]
+ assert_not_nil hash[:created_on]
+ assert_not_nil hash[:updated_on]
+ end
+
+ test "should include activity in hash" do
+ hash = @wrapper.to_hash
+
+ assert_not_nil hash[:activity]
+ assert_equal @activity.id, hash[:activity][:id]
+ assert_equal @activity.name, hash[:activity][:name]
+ end
+
+ test "should include user in hash" do
+ hash = @wrapper.to_hash
+
+ assert_not_nil hash[:user]
+ assert_equal @user.id, hash[:user][:id]
+ assert_equal @user.login, hash[:user][:login]
+ assert_equal @user.firstname, hash[:user][:firstname]
+ assert_equal @user.lastname, hash[:user][:lastname]
+ assert_equal @user.mail, hash[:user][:mail]
+ end
+
+ test "should include project in hash" do
+ hash = @wrapper.to_hash
+
+ assert_not_nil hash[:project]
+ assert_equal @project.id, hash[:project][:id]
+ assert_equal @project.identifier, hash[:project][:identifier]
+ assert_equal @project.name, hash[:project][:name]
+ end
+
+ test "should include issue in hash" do
+ hash = @wrapper.to_hash
+
+ assert_not_nil hash[:issue]
+ assert_equal @issue.id, hash[:issue][:id]
+ assert_equal @issue.subject, hash[:issue][:subject]
+ end
+
+ test "should include custom field values in hash" do
+ hash = @wrapper.to_hash
+
+ assert_not_nil hash[:custom_field_values]
+ assert hash[:custom_field_values].is_a?(Array)
+ assert hash[:custom_field_values].any? { |cfv| cfv[:custom_field_name] == 'Start time' }
+ assert hash[:custom_field_values].any? { |cfv| cfv[:custom_field_name] == 'End time' }
+ end
+
+ test "should handle time entry without issue" do
+ time_entry = create_overtime_time_entry
+ time_entry.update_column(:issue_id, nil)
+ time_entry.reload
+
+ wrapper = RedmineWebhook::TimeEntryWrapper.new(time_entry)
+ hash = wrapper.to_hash
+
+ assert_nil hash[:issue]
+ end
+
+ test "should handle time entry without activity" do
+ skip "Cannot test nil activity_id - database has NOT NULL constraint"
+ time_entry = create_overtime_time_entry
+ time_entry.update_column(:activity_id, nil)
+ time_entry.reload
+
+ wrapper = RedmineWebhook::TimeEntryWrapper.new(time_entry)
+ hash = wrapper.to_hash
+
+ assert_nil hash[:activity]
+ end
+
+ test "should handle empty custom field values" do
+ time_entry = TimeEntry.create!(
+ project: @project,
+ issue: @issue,
+ user: @user,
+ activity: @activity,
+ hours: 2.0,
+ spent_on: Date.today
+ )
+
+ wrapper = RedmineWebhook::TimeEntryWrapper.new(time_entry)
+ hash = wrapper.to_hash
+
+ assert_equal [], hash[:custom_field_values]
+ end
+end