From d355fee624f8f5dce68be15736edf83d4f858468 Mon Sep 17 00:00:00 2001 From: Minh Nguyen Date: Fri, 9 Jan 2026 10:25:16 +0700 Subject: [PATCH 1/5] fix ui: checkbox and text not on the same line --- app/views/settings/_redmine_one_webhook_settings.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 @@ From 5c6a54736693034573ec86169b885a06e3596654 Mon Sep 17 00:00:00 2001 From: Minh Nguyen Date: Fri, 9 Jan 2026 17:29:40 +0700 Subject: [PATCH 2/5] setup for one time test environment + execute unit tests and fix bugs --- .github/workflows/test.yml | 49 +++ lib/redmine_webhook/author_wrapper.rb | 7 +- lib/redmine_webhook/time_entry_wrapper.rb | 2 +- lib/redmine_webhook/webhook_listener.rb | 7 +- run_tests.sh | 81 ++++ setup_test.sh | 89 +++++ test/integration/webhook_integration_test.rb | 299 +++++++++++++++ test/support/factories.rb | 85 ++++ test/support/webhook_mock.rb | 71 ++++ test/test_helper.rb | 362 ++++++++++++++++++ test/unit/settings_test.rb | 203 ++++++++++ test/unit/time_entry_patch_test.rb | 259 +++++++++++++ test/unit/webhook_listener_test.rb | 304 +++++++++++++++ .../unit/wrapper_tests/author_wrapper_test.rb | 51 +++ .../custom_field_value_wrapper_test.rb | 32 ++ test/unit/wrapper_tests/issue_wrapper_test.rb | 57 +++ .../wrapper_tests/project_wrapper_test.rb | 31 ++ .../wrapper_tests/time_entry_wrapper_test.rb | 118 ++++++ 18 files changed, 2100 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100755 run_tests.sh create mode 100755 setup_test.sh create mode 100644 test/integration/webhook_integration_test.rb create mode 100644 test/support/factories.rb create mode 100644 test/support/webhook_mock.rb create mode 100644 test/unit/settings_test.rb create mode 100644 test/unit/time_entry_patch_test.rb create mode 100644 test/unit/webhook_listener_test.rb create mode 100644 test/unit/wrapper_tests/author_wrapper_test.rb create mode 100644 test/unit/wrapper_tests/custom_field_value_wrapper_test.rb create mode 100644 test/unit/wrapper_tests/issue_wrapper_test.rb create mode 100644 test/unit/wrapper_tests/project_wrapper_test.rb create mode 100644 test/unit/wrapper_tests/time_entry_wrapper_test.rb diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..dbbcf78 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,49 @@ +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: ['sqlite3', 'mysql:8.0', 'postgres:14'] + ruby-version: ['3.3', '3.4'] + redmine-version: ['5.1', 'master'] + exclude: + # Optimize CI time - only test with Ruby 3.4 for MySQL and PostgreSQL + - database: 'mysql:8.0' + ruby-version: '3.3' + - database: 'postgres:14' + ruby-version: '3.3' + # Reduce matrix further by testing master only with Ruby 3.4 and sqlite3 + - redmine-version: 'master' + ruby-version: '3.3' + - redmine-version: 'master' + database: 'mysql:8.0' + - redmine-version: 'master' + database: 'postgres:14' + + 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/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 From 9c5615f99597224a3e1d9b8acb35ad9fccd1a54e Mon Sep 17 00:00:00 2001 From: Minh Nguyen Date: Tue, 13 Jan 2026 08:46:05 +0700 Subject: [PATCH 3/5] update test ci setup for mysql --- .github/workflows/test.yml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dbbcf78..ee2aac7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,22 +12,18 @@ jobs: strategy: matrix: - database: ['sqlite3', 'mysql:8.0', 'postgres:14'] + database: ['mysql:8.0'] ruby-version: ['3.3', '3.4'] - redmine-version: ['5.1', 'master'] + redmine-version: ['5.1-stable', 'master'] exclude: - # Optimize CI time - only test with Ruby 3.4 for MySQL and PostgreSQL + # Optimize CI time - only test with Ruby 3.4 for MySQL - database: 'mysql:8.0' ruby-version: '3.3' - - database: 'postgres:14' - ruby-version: '3.3' - # Reduce matrix further by testing master only with Ruby 3.4 and sqlite3 + # Reduce matrix further by testing master only with Ruby 3.4 - redmine-version: 'master' ruby-version: '3.3' - redmine-version: 'master' database: 'mysql:8.0' - - redmine-version: 'master' - database: 'postgres:14' steps: - name: Setup Redmine From 12a5a075c7887cca228d47ade7e5f741e6c39e51 Mon Sep 17 00:00:00 2001 From: Minh Nguyen Date: Tue, 13 Jan 2026 08:49:31 +0700 Subject: [PATCH 4/5] fix workflow ruby version --- .github/workflows/test.yml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ee2aac7..05888e7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,17 +13,13 @@ jobs: strategy: matrix: database: ['mysql:8.0'] - ruby-version: ['3.3', '3.4'] + ruby-version: ['3.2', '3.3'] redmine-version: ['5.1-stable', 'master'] exclude: - # Optimize CI time - only test with Ruby 3.4 for MySQL - - database: 'mysql:8.0' + - redmine-version: '5.1-stable' ruby-version: '3.3' - # Reduce matrix further by testing master only with Ruby 3.4 - redmine-version: 'master' - ruby-version: '3.3' - - redmine-version: 'master' - database: 'mysql:8.0' + ruby-version: '3.2' steps: - name: Setup Redmine From 766de459b7f4a98ef6927d72eb7d6667dc982bb2 Mon Sep 17 00:00:00 2001 From: Minh Nguyen Date: Tue, 13 Jan 2026 09:01:37 +0700 Subject: [PATCH 5/5] simplify test matrix to match prod env --- .github/workflows/test.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 05888e7..c2a4b6b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,13 +13,8 @@ jobs: strategy: matrix: database: ['mysql:8.0'] - ruby-version: ['3.2', '3.3'] - redmine-version: ['5.1-stable', 'master'] - exclude: - - redmine-version: '5.1-stable' - ruby-version: '3.3' - - redmine-version: 'master' - ruby-version: '3.2' + ruby-version: ['3.2'] + redmine-version: ['5.1-stable'] steps: - name: Setup Redmine
- <%= 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