From f6155f3e089005fc59e97b83482f6968f21e6628 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 03:06:17 +0000 Subject: [PATCH 1/3] Initial plan From cca21e013f207610b9028c8ec70c96257b8e8032 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 03:17:05 +0000 Subject: [PATCH 2/3] Add PHPUnit tests for dispPlusadCancel and matchWhitelist with CI workflow Co-authored-by: Lastorder-DC <18280396+Lastorder-DC@users.noreply.github.com> --- .github/workflows/ci.yml | 46 ++++++ .gitignore | 4 + composer.json | 5 + phpunit.xml | 8 ++ tests/MatchWhitelistTest.php | 142 +++++++++++++++++++ tests/PlusadCancelTest.php | 247 ++++++++++++++++++++++++++++++++ tests/bootstrap.php | 266 +++++++++++++++++++++++++++++++++++ 7 files changed, 718 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 phpunit.xml create mode 100644 tests/MatchWhitelistTest.php create mode 100644 tests/PlusadCancelTest.php create mode 100644 tests/bootstrap.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0576d28 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + pull_request: + +jobs: + lint: + name: PHP ${{ matrix.php-version }} Syntax Check + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + + - name: Check PHP syntax + run: find . -name "*.php" -not -path "./vendor/*" -print0 | xargs -0 -n1 php -l + + test: + name: PHP ${{ matrix.php-version }} Tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + tools: composer + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run tests + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8fb047 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/vendor/ +composer.lock +.phpunit.cache/ +.phpunit.result.cache diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f66fa88 --- /dev/null +++ b/composer.json @@ -0,0 +1,5 @@ +{ + "require-dev": { + "phpunit/phpunit": "^9.6 || ^10.5 || ^11.0" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..945373c --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,8 @@ + + + + + tests + + + diff --git a/tests/MatchWhitelistTest.php b/tests/MatchWhitelistTest.php new file mode 100644 index 0000000..38ff2ca --- /dev/null +++ b/tests/MatchWhitelistTest.php @@ -0,0 +1,142 @@ +createController(); + $controller->module_info->domain_list = ''; + + // Empty URL always returns true + $this->assertTrue($controller->matchWhitelist('')); + + // Non-empty URLs are blocked when whitelist is empty + $this->assertFalse($controller->matchWhitelist('http://example.com')); + $this->assertFalse($controller->matchWhitelist('https://example.com')); + $this->assertFalse($controller->matchWhitelist('http://test.org')); + } + + /** + * Test: Null domain_list behaves same as empty + */ + public function testNullDomainListDisablesWhitelist(): void + { + $controller = $this->createController(); + $controller->module_info->domain_list = null; + + $this->assertTrue($controller->matchWhitelist('')); + $this->assertFalse($controller->matchWhitelist('http://example.com')); + } + + /** + * Test: Single domain (example.com) + * Only URLs on example.com should be allowed. + * Matching should be domain-only, path is irrelevant. + */ + public function testSingleDomainWhitelist(): void + { + $controller = $this->createController(); + $controller->module_info->domain_list = 'example.com'; + + // Matching domain passes + $this->assertTrue($controller->matchWhitelist('http://example.com')); + $this->assertTrue($controller->matchWhitelist('https://example.com')); + + // Matching domain with various paths passes (domain-only comparison) + $this->assertTrue($controller->matchWhitelist('http://example.com/')); + $this->assertTrue($controller->matchWhitelist('http://example.com/path/to/page')); + $this->assertTrue($controller->matchWhitelist('https://example.com/some/path')); + $this->assertTrue($controller->matchWhitelist('http://example.com/page?query=value')); + $this->assertTrue($controller->matchWhitelist('https://example.com/page#anchor')); + + // Non-matching domain fails + $this->assertFalse($controller->matchWhitelist('http://other.com')); + $this->assertFalse($controller->matchWhitelist('https://notexample.com')); + $this->assertFalse($controller->matchWhitelist('http://example.org')); + } + + /** + * Test: Two domains separated by \n (example.com and example1.com) + * Both domains should be allowed, others should be blocked. + */ + public function testMultipleDomainWhitelist(): void + { + $controller = $this->createController(); + $controller->module_info->domain_list = "example.com\nexample1.com"; + + // First domain passes + $this->assertTrue($controller->matchWhitelist('http://example.com')); + $this->assertTrue($controller->matchWhitelist('https://example.com')); + $this->assertTrue($controller->matchWhitelist('http://example.com/path')); + + // Second domain passes + $this->assertTrue($controller->matchWhitelist('http://example1.com')); + $this->assertTrue($controller->matchWhitelist('https://example1.com')); + $this->assertTrue($controller->matchWhitelist('http://example1.com/path')); + + // Non-matching domain fails + $this->assertFalse($controller->matchWhitelist('http://other.com')); + $this->assertFalse($controller->matchWhitelist('https://example2.com')); + $this->assertFalse($controller->matchWhitelist('http://notexample.com')); + } + + /** + * Test: Domain matching is path-independent + * Regardless of the path, only the domain should matter. + */ + public function testDomainMatchingIgnoresPath(): void + { + $controller = $this->createController(); + $controller->module_info->domain_list = 'example.com'; + + // Same domain with different paths all pass + $this->assertTrue($controller->matchWhitelist('http://example.com/')); + $this->assertTrue($controller->matchWhitelist('http://example.com/page')); + $this->assertTrue($controller->matchWhitelist('http://example.com/deep/path/to/resource')); + $this->assertTrue($controller->matchWhitelist('http://example.com/page?query=value')); + $this->assertTrue($controller->matchWhitelist('https://example.com/page#anchor')); + + // Different domain with various paths all fail + $this->assertFalse($controller->matchWhitelist('http://other.com/')); + $this->assertFalse($controller->matchWhitelist('http://other.com/page')); + $this->assertFalse($controller->matchWhitelist('http://other.com/deep/path')); + } + + /** + * Test: \r\n line endings are handled correctly + */ + public function testWindowsLineEndings(): void + { + $controller = $this->createController(); + $controller->module_info->domain_list = "example.com\r\nexample1.com"; + + $this->assertTrue($controller->matchWhitelist('http://example.com')); + $this->assertTrue($controller->matchWhitelist('http://example1.com')); + $this->assertFalse($controller->matchWhitelist('http://other.com')); + } +} diff --git a/tests/PlusadCancelTest.php b/tests/PlusadCancelTest.php new file mode 100644 index 0000000..d2d2233 --- /dev/null +++ b/tests/PlusadCancelTest.php @@ -0,0 +1,247 @@ +module_info->cancel_threshold_percent = 10; + $view->module_info->cancel_fee_percent = 10; + return $view; + } + + /** + * Helper to create ad data with a specific elapsed time percentage + * + * Uses a very large total duration (10,000,000 seconds ≈ 115 days) + * to minimize the impact of timing differences between test setup and execution. + */ + private function createAdData(int $member_srl, int $ad_point, float $elapsed_percent): stdClass + { + $total_seconds = 10000000; + $elapsed_seconds = intval($total_seconds * $elapsed_percent / 100); + $now = time(); + + $ad = new stdClass(); + $ad->ad_srl = 1; + $ad->member_srl = $member_srl; + $ad->ad_point = $ad_point; + $ad->regdate = date('Y-m-d H:i:s', $now - $elapsed_seconds); + $ad->enddate = date('Y-m-d H:i:s', $now - $elapsed_seconds + $total_seconds); + $ad->time = intval($total_seconds / 3600); + $ad->ad_content = 'Test ad'; + $ad->ad_url = ''; + return $ad; + } + + /** + * Helper to set up query results for a successful cancel flow + */ + private function setupQueryResults(stdClass $ad): void + { + // getad returns the ad data + $getOutput = new BaseObject(); + $getOutput->data = $ad; + TestHelper::setQueryResult('plusad.getad', $getOutput); + + // deletead succeeds + $deleteOutput = new BaseObject(); + TestHelper::setQueryResult('plusad.deletead', $deleteOutput); + } + + /** + * Test: Cancel without login → error + */ + public function testCancelWithoutLogin(): void + { + $view = $this->createView(); + Context::set('ad_srl', 1); + Context::set('logged_info', null); + + $result = $view->dispPlusadCancel(); + + $this->assertInstanceOf(BaseObject::class, $result); + $this->assertFalse($result->toBool()); + $this->assertEquals('로그인후 이용가능합니다', $result->getMessage()); + } + + /** + * Test: Cancel when ad is not found → error + */ + public function testCancelAdNotFound(): void + { + $view = $this->createView(); + + $logged_info = new stdClass(); + $logged_info->member_srl = 1; + Context::set('logged_info', $logged_info); + Context::set('ad_srl', 999); + + // getad returns null (ad not found) + $output = new BaseObject(); + $output->data = null; + TestHelper::setQueryResult('plusad.getad', $output); + + $result = $view->dispPlusadCancel(); + + $this->assertInstanceOf(BaseObject::class, $result); + $this->assertFalse($result->toBool()); + $this->assertEquals('광고 정보를 찾을 수 없습니다.', $result->getMessage()); + } + + /** + * Test: Cancel ad that belongs to another user → error + */ + public function testCancelNotOwnAd(): void + { + $view = $this->createView(); + + $logged_info = new stdClass(); + $logged_info->member_srl = 1; + Context::set('logged_info', $logged_info); + Context::set('ad_srl', 1); + + // Ad belongs to a different user (member_srl = 2) + $ad = $this->createAdData(2, 10000, 5); + $output = new BaseObject(); + $output->data = $ad; + TestHelper::setQueryResult('plusad.getad', $output); + + $result = $view->dispPlusadCancel(); + + $this->assertInstanceOf(BaseObject::class, $result); + $this->assertFalse($result->toBool()); + $this->assertEquals('본인의 광고만 취소할 수 있습니다.', $result->getMessage()); + } + + /** + * Test: Cancel with <0.5% elapsed → success with correct refund + * + * ad_point = 10000, elapsed ≈ 0.3%, cancel_fee = 10% + * deduction = round(0.3) + 10 = 0 + 10 = 10% + * refund = intval(10000 × (100 - 10) / 100) = 9000 + */ + public function testCancelUnderHalfPercent(): void + { + $view = $this->createView(); + + $logged_info = new stdClass(); + $logged_info->member_srl = 1; + Context::set('logged_info', $logged_info); + Context::set('ad_srl', 1); + Context::set('success_return_url', '/redirect'); + + $ad = $this->createAdData(1, 10000, 0.3); + $this->setupQueryResults($ad); + + $result = $view->dispPlusadCancel(); + + // Success: function returns void (null) + $this->assertNull($result); + + // Verify point refund + $this->assertCount(1, TestHelper::$pointCalls); + $pointCall = TestHelper::$pointCalls[0]; + $this->assertEquals(1, $pointCall['member_srl']); + $this->assertEquals(9000, $pointCall['point']); + $this->assertEquals('add', $pointCall['mode']); + + // Verify deletead was called + $deleteQueries = array_filter(TestHelper::$queries, function ($q) { + return $q['queryId'] === 'plusad.deletead'; + }); + $this->assertCount(1, $deleteQueries); + } + + /** + * Test: Cancel with ~10% elapsed → success with correct refund + * + * ad_point = 10000, elapsed ≈ 9.99% (just under 10% threshold), cancel_fee = 10% + * deduction = round(9.99) + 10 = 10 + 10 = 20% + * refund = intval(10000 × (100 - 20) / 100) = 8000 + */ + public function testCancelAtTenPercent(): void + { + $view = $this->createView(); + + $logged_info = new stdClass(); + $logged_info->member_srl = 1; + Context::set('logged_info', $logged_info); + Context::set('ad_srl', 1); + Context::set('success_return_url', '/redirect'); + + // Use 9.99% elapsed (just under the 10% threshold) + $ad = $this->createAdData(1, 10000, 9.99); + $this->setupQueryResults($ad); + + $result = $view->dispPlusadCancel(); + + // Success: function returns void (null) + $this->assertNull($result); + + // Verify point refund + $this->assertCount(1, TestHelper::$pointCalls); + $pointCall = TestHelper::$pointCalls[0]; + $this->assertEquals(1, $pointCall['member_srl']); + $this->assertEquals(8000, $pointCall['point']); + $this->assertEquals('add', $pointCall['mode']); + + // Verify deletead was called + $deleteQueries = array_filter(TestHelper::$queries, function ($q) { + return $q['queryId'] === 'plusad.deletead'; + }); + $this->assertCount(1, $deleteQueries); + } + + /** + * Test: Cancel with 20% elapsed → failure (exceeds threshold) + */ + public function testCancelAtTwentyPercentFails(): void + { + $view = $this->createView(); + + $logged_info = new stdClass(); + $logged_info->member_srl = 1; + Context::set('logged_info', $logged_info); + Context::set('ad_srl', 1); + + $ad = $this->createAdData(1, 10000, 20); + $output = new BaseObject(); + $output->data = $ad; + TestHelper::setQueryResult('plusad.getad', $output); + + $result = $view->dispPlusadCancel(); + + // Should return error + $this->assertInstanceOf(BaseObject::class, $result); + $this->assertFalse($result->toBool()); + $this->assertStringContainsString('10%', $result->getMessage()); + + // No point refund should occur + $this->assertCount(0, TestHelper::$pointCalls); + + // No delete query should be executed + $deleteQueries = array_filter(TestHelper::$queries, function ($q) { + return $q['queryId'] === 'plusad.deletead'; + }); + $this->assertCount(0, $deleteQueries); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..8b6db2b --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,266 @@ +error = $error; + $this->message = $message; + } + + function toBool() + { + return ($this->error == 0); + } + + function getMessage() + { + return $this->message; + } +} + +/** + * ModuleObject - Rhymix module object emulation + */ +class ModuleObject extends BaseObject +{ + public $module_info; + public $module_path = ''; + public $grant; + public $mid = ''; + protected $messages = []; + protected $redirect_url = ''; + protected $template_path = ''; + protected $template_file = ''; + private static $instances = []; + + function __construct() + { + $this->module_info = new stdClass(); + $this->grant = new stdClass(); + } + + public static function getInstance() + { + $className = get_called_class(); + if (!isset(self::$instances[$className])) + { + self::$instances[$className] = new $className(); + } + return self::$instances[$className]; + } + + public static function resetInstances() + { + self::$instances = []; + } + + function setMessage($message) + { + $this->messages[] = $message; + } + + function getLastMessage() + { + return end($this->messages) ?: ''; + } + + function setRedirectUrl($url) + { + $this->redirect_url = $url; + } + + function getRedirectUrl() + { + return $this->redirect_url; + } + + function setTemplatePath($path) + { + $this->template_path = $path; + } + + function setTemplateFile($file) + { + $this->template_file = $file; + } +} + +/** + * Context - Rhymix context emulation + */ +class Context +{ + private static $vars = []; + + public static function get($key) + { + return isset(self::$vars[$key]) ? self::$vars[$key] : null; + } + + public static function set($key, $value) + { + self::$vars[$key] = $value; + } + + public static function getRequestVars() + { + return (object)self::$vars; + } + + public static function reset() + { + self::$vars = []; + } +} + +/** + * DB - Rhymix database emulation + */ +class DB +{ + private static $instance; + + public static function getInstance() + { + if (!self::$instance) + { + self::$instance = new self(); + } + return self::$instance; + } + + function isColumnExists($table, $column) + { + return true; + } + + function addColumn($table, $column, $type, $size = 0, $default = '', $notnull = false) + { + } + + function dropColumn($table, $column) + { + } +} + +/** + * moduleModel - Rhymix module model emulation + */ +class moduleModel +{ + public static function getModuleInfoByModuleSrl($module_srl) + { + $info = new stdClass(); + $info->module_srl = $module_srl; + return $info; + } +} + +/** + * TestHelper - Utility class for tracking function calls in tests + */ +class TestHelper +{ + public static $queries = []; + public static $pointCalls = []; + public static $executeQueryResults = []; + + public static function reset() + { + self::$queries = []; + self::$pointCalls = []; + self::$executeQueryResults = []; + } + + public static function setQueryResult($queryId, $result) + { + self::$executeQueryResults[$queryId] = $result; + } +} + +/** + * executeQuery - Rhymix query execution emulation + */ +function executeQuery($queryId, $args = null) +{ + TestHelper::$queries[] = ['queryId' => $queryId, 'args' => $args]; + + if (isset(TestHelper::$executeQueryResults[$queryId])) + { + $result = TestHelper::$executeQueryResults[$queryId]; + if (is_callable($result)) + { + return $result($args); + } + return $result; + } + + $output = new BaseObject(); + $output->data = null; + return $output; +} + +/** + * pointController - Rhymix point controller emulation + */ +class pointController +{ + public static function setPoint($member_srl, $point, $mode) + { + TestHelper::$pointCalls[] = [ + 'member_srl' => $member_srl, + 'point' => $point, + 'mode' => $mode, + ]; + } +} + +/** + * pointModel - Rhymix point model emulation + */ +class pointModel +{ + private static $points = []; + + public static function setTestPoint($member_srl, $point) + { + self::$points[$member_srl] = $point; + } + + public static function getPoint($member_srl) + { + return isset(self::$points[$member_srl]) ? self::$points[$member_srl] : 10000; + } + + public static function reset() + { + self::$points = []; + } +} + +/** + * getNotEncodedUrl - Rhymix URL helper emulation + */ +function getNotEncodedUrl() +{ + return ''; +} + +// Include module files +require_once __DIR__ . '/../plusad.class.php'; +require_once __DIR__ . '/../plusad.controller.php'; +require_once __DIR__ . '/../plusad.model.php'; +require_once __DIR__ . '/../plusad.view.php'; From 9440b587f4b711feaf7c69d1228ee6b2a3f0b57f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 03:18:04 +0000 Subject: [PATCH 3/3] Add explicit permissions block to CI workflow Co-authored-by: Lastorder-DC <18280396+Lastorder-DC@users.noreply.github.com> --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0576d28..a4dc9cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,9 @@ on: push: pull_request: +permissions: + contents: read + jobs: lint: name: PHP ${{ matrix.php-version }} Syntax Check