diff --git a/lib/Controller/API/App/SetTranslationController.php b/lib/Controller/API/App/SetTranslationController.php index 17cd070730..f31a0f1149 100644 --- a/lib/Controller/API/App/SetTranslationController.php +++ b/lib/Controller/API/App/SetTranslationController.php @@ -6,6 +6,7 @@ use Controller\API\Commons\Exceptions\AuthenticationError; use Controller\API\Commons\Validators\LoginValidator; use Controller\Traits\APISourcePageGuesserTrait; +use Controller\Traits\SegmentDisabledTrait; use Exception; use InvalidArgumentException; use Matecat\ICU\MessagePatternComparator; @@ -57,7 +58,7 @@ class SetTranslationController extends AbstractStatefulKleinController { - + use SegmentDisabledTrait; use APISourcePageGuesserTrait; use ICUSourceSegmentChecker; @@ -137,6 +138,7 @@ public function translate(): void try { $this->data = $this->validateTheRequest(); + $this->checkIfSegmentIsNotDisabled(); $this->setSubFilteringBehavior(); $this->checkSegmentSplitData(); $this->initVersionHandler(); @@ -408,6 +410,7 @@ public function translate(): void $this->response->json($result); } catch (Exception $exception) { $db->rollback(); + $this->logger->error($exception->getMessage()); throw $exception; } } @@ -554,6 +557,26 @@ private function validateTheRequest(): array return $data; } + /** + * checkIfIsNotDisabled + * + * Determines whether the segment associated with a specific job and segment ID + * is disabled by checking cached information. If the segment is found to be disabled, + * an exception is thrown. + * + * @return void + * @throws Exception If the segment is disabled. + */ + private function checkIfSegmentIsNotDisabled(): void + { + $id_job = $this->data['id_job']; + $id_segment = $this->data['id_segment']; + + if ($this->isSegmentDisabled((int)$id_job, (int)$id_segment)) { + throw new RuntimeException("Segment #".$id_segment." is disabled", -5); + } +} + /** * @return bool */ diff --git a/lib/Controller/API/V3/CancelRequestController.php b/lib/Controller/API/V3/CancelRequestController.php new file mode 100644 index 0000000000..6ad799b2d6 --- /dev/null +++ b/lib/Controller/API/V3/CancelRequestController.php @@ -0,0 +1,191 @@ +appendValidator(new LoginValidator($this)); + } + + /** + * @throws Exception + */ + public function enableRequest(): void + { + $rawIdJob = $this->request->param('id_job'); + $password = $this->request->param('password'); + $rawIdSegment = $this->request->param('id_segment'); + $id_job = filter_var($rawIdJob, FILTER_VALIDATE_INT); + $id_segment = filter_var($rawIdSegment, FILTER_VALIDATE_INT); + $route = '/api/v3/jobs/'.$id_job.'/'.$password.'/segment/enable/'.$id_segment; + + $this->performChecks($id_job, $password, $id_segment, $route); + + if ($this->response->code() === 429) { + return; + } + + if($this->isSegmentDisabled($id_job, $id_segment)){ + $this->destroySegmentDisabledCache($id_job, $id_segment); + } + + $this->response->json([ + 'id_segment' => $id_segment, + ]); + } + + /** + * @throws Exception + */ + public function cancelRequest(): void + { + $rawIdJob = $this->request->param('id_job'); + $password = $this->request->param('password'); + $rawIdSegment = $this->request->param('id_segment'); + $id_job = filter_var($rawIdJob, FILTER_VALIDATE_INT); + $id_segment = filter_var($rawIdSegment, FILTER_VALIDATE_INT); + $route = '/api/v3/jobs/'.$id_job.'/'.$password.'/segment/disable/'.$id_segment; + + $this->performChecks($id_job, $password, $id_segment, $route); + + if ($this->response->code() === 429) { + return; + } + + // If the cache is empty, it means that the segment is not already disabled, so we can proceed with disabling it and + // setting the cache to avoid multiple disable requests for the same segment in a short time frame + if (!$this->isSegmentDisabled($id_job, $id_segment)) { + SegmentMetadataDao::destroyGetAllCache($id_segment); + SegmentMetadataDao::setTranslationDisabled($id_segment); + $this->saveSegmentDisabledInCache($id_job, $id_segment); + } + + $this->response->json([ + 'id_segment' => $id_segment, + ]); + } + + /** + * Performs several validation checks and rate limit handling for disabling a segment in a job. + * + * @param int $id_job The unique identifier of the job. + * @param string $password The password associated with the job for authentication. + * @param int $id_segment The unique identifier of the segment to be validated. + * @param string $route The API route being accessed. + * + * @throws NotFoundException If the job or segment is not found. + * @throws Exception If the user is not the owner or part of the team, or if the segment status is not "new". + */ + private function performChecks(int $id_job, string $password, int $id_segment, string $route): void + { + $userEmail = $this->user->email ?? "BLANK_EMAIL"; + $userIp = Utils::getRealIpAddr() ?? "127.0.0.1"; + + // 1. check rate limit + $checkRateLimitEmail = $this->checkRateLimitResponse($this->response, $userEmail, $route, 5); + $checkRateLimitIp = $this->checkRateLimitResponse($this->response, $userIp, $route, 5); + + if ($checkRateLimitIp instanceof Response) { + $this->response = $checkRateLimitIp; + + return; + } + + if ($checkRateLimitEmail instanceof Response) { + $this->response = $checkRateLimitEmail; + + return; + } + + // 2. check job id and password + $job = $this->getJob($id_job, $password); + + if (null === $job) { + $this->incrementRateLimitCounter($userEmail, $route); + $this->incrementRateLimitCounter($userIp, $route); + + throw new NotFoundException('Job not found.'); + } + + // 3. check segment translation + $segmentTranslation = $this->findSegmentTranslation($id_segment, $id_job); + + if (empty($segmentTranslation)) { + $this->incrementRateLimitCounter($userEmail, $route); + $this->incrementRateLimitCounter($userIp, $route); + + throw new NotFoundException('Segment not found'); + } + + // 4. check is user is the owner of the segment + $team = $job->getProject()->getTeam(); + + if(empty($team)){ + $this->incrementRateLimitCounter($userEmail, $route); + $this->incrementRateLimitCounter($userIp, $route); + + throw new NotFoundException('Team not found'); + } + + if(!empty($this->getUser()->uid) && $team->created_by != $this->getUser()->uid){ + + // check if user is part of the team + if (!$team->hasUser($this->getUser()->uid)){ + $this->incrementRateLimitCounter($userEmail, $route); + $this->incrementRateLimitCounter($userIp, $route); + + throw new Exception('User is not part of the team'); + } + } + + // 5. check segment status + if ($segmentTranslation->status !== TranslationStatus::STATUS_NEW) { + $this->incrementRateLimitCounter($userEmail, $route); + $this->incrementRateLimitCounter($userIp, $route); + + throw new Exception('Segment is not in "new" status and cannot be disabled'); + } + + $this->incrementRateLimitCounter($userEmail, $route); + $this->incrementRateLimitCounter($userIp, $route); + } + + /** + * @param int $id_segment + * @param int $id_job + * + * @return ?SegmentTranslationStruct + */ + protected function findSegmentTranslation(int $id_segment, int $id_job): ?SegmentTranslationStruct + { + return SegmentTranslationDao::findBySegmentAndJob($id_segment, $id_job); + } +} \ No newline at end of file diff --git a/lib/Controller/API/V3/SegmentAnalysisController.php b/lib/Controller/API/V3/SegmentAnalysisController.php index ec9a61f579..d346b78fcc 100644 --- a/lib/Controller/API/V3/SegmentAnalysisController.php +++ b/lib/Controller/API/V3/SegmentAnalysisController.php @@ -4,6 +4,7 @@ use Controller\Abstracts\KleinController; use Controller\API\Commons\Validators\LoginValidator; +use Controller\Traits\SegmentDisabledTrait; use Exception; use Matecat\SubFiltering\MateCatFilter; use Model\Analysis\Constants\ConstantsInterface; @@ -29,6 +30,7 @@ class SegmentAnalysisController extends KleinController { + use SegmentDisabledTrait; const int MAX_PER_PAGE = 200; @@ -346,6 +348,8 @@ private function formatSegment( $metadataDao->getProjectStaticSubfilteringCustomHandlers($jobStruct->id_project) ); + $disabled = $this->isSegmentDisabled($segmentForAnalysis->id_job, $segmentForAnalysis->id); + return [ 'id_segment' => (int)$segmentForAnalysis->id, 'id_chunk' => (int)$segmentForAnalysis->id_job, @@ -366,6 +370,7 @@ private function formatSegment( 'notes' => (!empty($notesAggregate[$segmentForAnalysis->id]) ? $notesAggregate[$segmentForAnalysis->id] : []), 'status' => $this->getStatusObject($segmentForAnalysis), 'last_edit' => ($segmentForAnalysis->last_edit !== null) ? date(DATE_ATOM, strtotime($segmentForAnalysis->last_edit)) : null, + 'disabled' => $disabled, ]; } diff --git a/lib/Controller/Traits/ChunkNotFoundHandlerTrait.php b/lib/Controller/Traits/ChunkNotFoundHandlerTrait.php index 280875cf76..91a7509810 100644 --- a/lib/Controller/Traits/ChunkNotFoundHandlerTrait.php +++ b/lib/Controller/Traits/ChunkNotFoundHandlerTrait.php @@ -22,7 +22,7 @@ trait ChunkNotFoundHandlerTrait * @return ?JobStruct * @throws ReflectionException */ - protected function getJob($id_job, $password): ?JobStruct + protected function getJob(int $id_job, string $password): ?JobStruct { $job = JobDao::getByIdAndPassword($id_job, $password); diff --git a/lib/Controller/Traits/RateLimiterTrait.php b/lib/Controller/Traits/RateLimiterTrait.php index d7d8a6d5f8..199c360e45 100644 --- a/lib/Controller/Traits/RateLimiterTrait.php +++ b/lib/Controller/Traits/RateLimiterTrait.php @@ -93,6 +93,6 @@ private function getTtl(): int $date = new DateTime(); $ttl = 60 - $date->format("s"); - return 60 + $ttl; + return 60 + (int)$ttl; } } \ No newline at end of file diff --git a/lib/Controller/Traits/SegmentDisabledTrait.php b/lib/Controller/Traits/SegmentDisabledTrait.php new file mode 100644 index 0000000000..e5dd75ff5b --- /dev/null +++ b/lib/Controller/Traits/SegmentDisabledTrait.php @@ -0,0 +1,99 @@ +cacheKeyAndQuery($id_job, $id_segment); + $this->cacheInit(); + $this->_deleteCacheByKey($cache['key'], false); + } + + /** + * Checks if a specific segment is disabled for a given job. + * + * @param int $id_job The unique identifier of the job. + * @param int $id_segment The unique identifier of the segment. + * @return bool Returns true if the segment is disabled, false otherwise. + * @throws ReflectionException + */ + protected function isSegmentDisabled(int $id_job, int $id_segment): bool + { + $cache = $this->cacheKeyAndQuery($id_job, $id_segment); + $this->cacheInit(); + $cachedValue = $this->_getFromCacheMap($cache['key'], $cache['query']); + + // retrieve from cache fails, we need to check the database and update the cache accordingly + if (empty($cachedValue)) { + $metadataList = SegmentMetadataDao::get($id_segment, 'translation_disabled'); + $metadata = $metadataList[0] ?? null; + $isDisabled = (($metadata->meta_value ?? '0') === '1'); + $this->_setInCacheMap($cache['key'], $cache['query'], [$isDisabled ? 1 : 0]); + + return $isDisabled; + } + + return $cachedValue == [1]; + } + + /** + * Saves a segment's disabled state in the cache with a specific key and value. + * + * @param int $id_job The identifier for the job associated with the segment. + * @param int $id_segment The identifier for the segment to be marked as disabled in the cache. + * + * @return void + */ + protected function saveSegmentDisabledInCache(int $id_job, int $id_segment): void + { + $cache = $this->cacheKeyAndQuery($id_job, $id_segment); + $this->cacheInit(); + $this->_setInCacheMap($cache['key'], $cache['query'], [1]); + } + + /** + * Generates a cache key and query string for a specific segment. + */ + private function cacheKeyAndQuery(int $id_job, int $id_segment): array + { + $cacheKey = 'segment_is_disabled_' . $id_job . '_' . $id_segment; + $cachedQuery = "__SEGMENT_IS_DISABLED__" . $id_job . "_" . $id_segment; + + return [ + 'key' => $cacheKey, + 'query' => $cachedQuery, + ]; + } + + /** + * Initializes the cache system by setting the time-to-live (TTL) and establishing the cache connection. + * + * @return void + */ + private function cacheInit(): void + { + $this->setCacheTTL(self::CACHE_TTL); + $this->_cacheSetConnection(); + } +} diff --git a/lib/Model/DataAccess/Database.php b/lib/Model/DataAccess/Database.php index 9537be4e64..3c930b3251 100644 --- a/lib/Model/DataAccess/Database.php +++ b/lib/Model/DataAccess/Database.php @@ -179,7 +179,12 @@ public function commit(): void */ public function rollback(): void { - $this->getConnection()->rollBack(); + $connection = $this->getConnection(); + + // Check if a transaction is currently active + if ($connection->inTransaction()) { + $connection->rollBack(); + } } /** diff --git a/lib/Model/ProjectCreation/SegmentStorageService.php b/lib/Model/ProjectCreation/SegmentStorageService.php index e060cd4da7..d10eace2c4 100644 --- a/lib/Model/ProjectCreation/SegmentStorageService.php +++ b/lib/Model/ProjectCreation/SegmentStorageService.php @@ -389,7 +389,7 @@ protected function saveSegmentMetadata(int $id_segment, ?SegmentMetadataStruct $ isset($metadataStruct->meta_key) && $metadataStruct->meta_key !== '' && isset($metadataStruct->meta_value) && $metadataStruct->meta_value !== '' ) { - $metadataStruct->id_segment = (string)$id_segment; + $metadataStruct->id_segment = $id_segment; $this->persistSegmentMetadata($metadataStruct); } } diff --git a/lib/Model/Segments/SegmentMetadataDao.php b/lib/Model/Segments/SegmentMetadataDao.php index a7f437ddd0..0a7789e0bb 100644 --- a/lib/Model/Segments/SegmentMetadataDao.php +++ b/lib/Model/Segments/SegmentMetadataDao.php @@ -8,7 +8,8 @@ class SegmentMetadataDao extends AbstractDao { - + private static string $sql_get_all = "SELECT * FROM segment_metadata WHERE id_segment = ? "; + private static string $sql_find_by_id_segment_and_key = "SELECT * FROM segment_metadata WHERE id_segment = ? and meta_key = ? "; /** * get all meta * @@ -24,7 +25,7 @@ public static function getAll(int $id_segment, int $ttl = 604800): array { $thisDao = new self(); $conn = $thisDao->getDatabaseHandler(); - $stmt = $conn->getConnection()->prepare("SELECT * FROM segment_metadata WHERE id_segment = ? "); + $stmt = $conn->getConnection()->prepare(self::$sql_get_all); return $thisDao->setCacheTTL($ttl)->_fetchObjectMap( $stmt, @@ -33,6 +34,23 @@ public static function getAll(int $id_segment, int $ttl = 604800): array ); } + /** + * Destroys the cached metadata for the specified segment. + * + * @param int $id_segment The ID of the segment whose cache needs to be destroyed. + * + * @return bool True if the cache was successfully destroyed, false otherwise. + * @throws ReflectionException + */ + public static function destroyGetAllCache(int $id_segment): bool + { + $thisDao = new self(); + $conn = Database::obtain()->getConnection(); + $stmt = $conn->prepare(self::$sql_get_all); + + return $thisDao->_destroyObjectCache($stmt, SegmentMetadataStruct::class, [$id_segment]); + } + /** * @param array $ids * @param string $key @@ -70,7 +88,7 @@ public static function get(int $id_segment, string $key, int $ttl = 604800): arr { $thisDao = new self(); $conn = $thisDao->getDatabaseHandler(); - $stmt = $conn->getConnection()->prepare("SELECT * FROM segment_metadata WHERE id_segment = ? and meta_key = ? "); + $stmt = $conn->getConnection()->prepare(self::$sql_find_by_id_segment_and_key); return $thisDao->setCacheTTL($ttl)->_fetchObjectMap( $stmt, @@ -79,6 +97,30 @@ public static function get(int $id_segment, string $key, int $ttl = 604800): arr ); } + /** + * Destroy cache of segment metadata based on segment ID and key. + * + * @param int $id_segment The identifier of the segment to target. + * @param string $key The key associated with the cache entry to be destroyed. + * + * @return bool True if the cache was successfully destroyed, false otherwise. + */ + public static function destroyCache(int $id_segment, string $key): bool + { + $thisDao = new self(); + $conn = Database::obtain()->getConnection(); + $stmt = $conn->prepare(self::$sql_find_by_id_segment_and_key); + + return $thisDao->_destroyObjectCache($stmt, SegmentMetadataStruct::class, [$id_segment, $key]); + } + + public static function delete(int $id_segment, string $key): void + { + $conn = Database::obtain()->getConnection(); + $stmt = $conn->prepare("DELETE FROM segment_metadata WHERE id_segment = ? AND meta_key = ?"); + $stmt->execute([$id_segment, $key]); + } + /** * @param SegmentMetadataStruct $metadataStruct */ @@ -97,4 +139,21 @@ public static function save(SegmentMetadataStruct $metadataStruct): void 'value' => $metadataStruct->meta_value, ]); } + + /** + * Disable translation for a specific segment. + * + * @param int $id_segment The ID of the segment for which translation will be disabled. + * + * @return void + */ + public static function setTranslationDisabled(int $id_segment): void + { + $metadata = new SegmentMetadataStruct(); + $metadata->id_segment = $id_segment; + $metadata->meta_key = 'translation_disabled'; + $metadata->meta_value = "1"; + + SegmentMetadataDao::save($metadata); + } } \ No newline at end of file diff --git a/lib/Model/Segments/SegmentMetadataStruct.php b/lib/Model/Segments/SegmentMetadataStruct.php index 5e3924efd8..293a954721 100644 --- a/lib/Model/Segments/SegmentMetadataStruct.php +++ b/lib/Model/Segments/SegmentMetadataStruct.php @@ -8,7 +8,7 @@ class SegmentMetadataStruct extends AbstractDaoSilentStruct implements IDaoStruct { - public ?string $id_segment = null; + public int $id_segment; public string $meta_key; public string $meta_value; } \ No newline at end of file diff --git a/lib/Routes/api_v3_routes.php b/lib/Routes/api_v3_routes.php index 850d94a5d2..01f5cc6719 100644 --- a/lib/Routes/api_v3_routes.php +++ b/lib/Routes/api_v3_routes.php @@ -34,6 +34,9 @@ route('/cancel', 'POST', ['Controller\API\V2\JobsController', 'cancel']); route('/archive', 'POST', ['Controller\API\V2\JobsController', 'archive']); route('/active', 'POST', ['Controller\API\V2\JobsController', 'active']); + + route('/segment/disable/[i:id_segment]', 'POST', ['\Controller\API\V3\CancelRequestController', 'cancelRequest']); + route('/segment/enable/[i:id_segment]', 'POST', ['\Controller\API\V3\CancelRequestController', 'enableRequest']); }); $klein->with('/api/v3/teams', function () { diff --git a/plugins/airbnb b/plugins/airbnb index 0d6152c9c4..ac7921b89a 160000 --- a/plugins/airbnb +++ b/plugins/airbnb @@ -1 +1 @@ -Subproject commit 0d6152c9c48bff972f5b7a591b507389d0a830e0 +Subproject commit ac7921b89aae4e3f0b9ab03851c993d99f2db755 diff --git a/plugins/translated b/plugins/translated index c957a29a76..b3f19a2db6 160000 --- a/plugins/translated +++ b/plugins/translated @@ -1 +1 @@ -Subproject commit c957a29a768561735bdec0276d7fce45aeedcecd +Subproject commit b3f19a2db6f36dc8a2fa278eb0df5c2135404fa5 diff --git a/plugins/uber b/plugins/uber index cc5299817e..c6beac3fda 160000 --- a/plugins/uber +++ b/plugins/uber @@ -1 +1 @@ -Subproject commit cc5299817e346708c072bb2643362d0b69f466b6 +Subproject commit c6beac3fdacce11cc78db017ddf41fdb7fd766c7 diff --git a/public/api/swagger-source.js b/public/api/swagger-source.js index d7d4e75ef6..fa2cb5bf37 100644 --- a/public/api/swagger-source.js +++ b/public/api/swagger-source.js @@ -918,6 +918,98 @@ var spec = { }, }, }, + '/api/v3/jobs/{id_job}/{password}/segment/disable/{id_segment}': { + post: { + parameters: [ + { + name: 'id_job', + in: 'path', + description: + 'The id of the parent job of the segment you intend to disable', + required: true, + type: 'integer', + }, + { + name: 'password', + in: 'path', + description: + 'The password of the parent job of the segment you intend to disable', + required: true, + type: 'string', + }, + { + name: 'id_segment', + in: 'path', + description: + 'The id of the segment you intend to disable', + required: true, + type: 'integer', + } + ], + responses: { + 200: { + schema: { + type: 'object', + properties: { + id_segment: { + type: 'integer', + example: 123, + } + }, + }, + }, + default: { + description: 'Unexpected error', + }, + }, + } + }, + '/api/v3/jobs/{id_job}/{password}/segment/enable/{id_segment}': { + post: { + parameters: [ + { + name: 'id_job', + in: 'path', + description: + 'The id of the parent job of the segment you intend to enable', + required: true, + type: 'integer', + }, + { + name: 'password', + in: 'path', + description: + 'The password of the parent job of the segment you intend to enable', + required: true, + type: 'string', + }, + { + name: 'id_segment', + in: 'path', + description: + 'The id of the segment you intend to enable', + required: true, + type: 'integer', + } + ], + responses: { + 200: { + schema: { + type: 'object', + properties: { + id_segment: { + type: 'integer', + example: 123, + } + }, + }, + }, + default: { + description: 'Unexpected error', + }, + }, + } + }, '/api/v3/projects/{id_project}/{password}/r2': { post: { tags: ['Project'], diff --git a/public/js/actions/CatToolActions.js b/public/js/actions/CatToolActions.js index 375b019ccf..23c7b4680b 100644 --- a/public/js/actions/CatToolActions.js +++ b/public/js/actions/CatToolActions.js @@ -370,7 +370,17 @@ let CatToolActions = { const codeInt = parseInt(error.code) if (operation === 'setTranslation') { - if (codeInt !== -10) { + if (codeInt === -5) { + ModalsActions.showModalComponent( + AlertModal, + { + text: 'This segment has been disabled by the project owner.
Refresh the page to update segment status.', + buttonText: 'Refresh page', + successCallback: () => CatToolActions.onRender(), + }, + 'Segment disabled', + ) + } else if (codeInt !== -10) { ModalsActions.showModalComponent( AlertModal, { diff --git a/public/js/actions/CatToolActions.test.js b/public/js/actions/CatToolActions.test.js new file mode 100644 index 0000000000..3c764ad230 --- /dev/null +++ b/public/js/actions/CatToolActions.test.js @@ -0,0 +1,122 @@ +jest.mock('../stores/AppDispatcher', () => ({ + dispatch: jest.fn(), + register: jest.fn(), +})) + +jest.mock('../stores/CatToolStore', () => ({ + getCurrentSegment: jest.fn(), + getMatecatEventsEnabled: jest.fn(() => false), +})) + +jest.mock('../stores/SegmentStore', () => ({ + getCurrentSegmentId: jest.fn(), + getSegmentsArray: jest.fn(() => []), +})) + +jest.mock('./ModalsActions', () => ({ + showModalComponent: jest.fn(), +})) + +jest.mock('./SegmentActions', () => ({})) + +jest.mock('../utils/commonUtils', () => ({ + dispatchCustomEvent: jest.fn(), +})) +jest.mock('../utils/offlineUtils', () => ({ + failedConnection: jest.fn(), +})) + +// API imports (only those actually imported by CatToolActions.js) +jest.mock('../api/getJobStatistics', () => ({ getJobStatistics: jest.fn() })) +jest.mock('../api/sendRevisionFeedback', () => ({ + sendRevisionFeedback: jest.fn(), +})) +jest.mock('../api/getTmKeysJob', () => ({ getTmKeysJob: jest.fn() })) +jest.mock('../api/getDomainsList', () => ({ getDomainsList: jest.fn() })) +jest.mock('../api/checkJobKeysHaveGlossary', () => ({ + checkJobKeysHaveGlossary: jest.fn(), +})) +jest.mock('../api/getJobMetadata', () => ({ getJobMetadata: jest.fn() })) +jest.mock('../api/getGlobalWarnings', () => ({ getGlobalWarnings: jest.fn() })) + +jest.mock('../components/modals/AlertModal', () => 'AlertModal') +jest.mock('../components/modals/RevisionFeedbackModal', () => 'RevisionFeedbackModal') +jest.mock('../components/modals/ConfirmMessageModal', () => 'ConfirmMessageModal') + +jest.mock('../constants/CatToolConstants', () => ({ + SET_FIRST_LOAD: 'SET_FIRST_LOAD', +})) +jest.mock('lodash', () => ({ + isUndefined: (v) => typeof v === 'undefined', +})) + +import CatToolActions from './CatToolActions' +import ModalsActions from './ModalsActions' +import AlertModal from '../components/modals/AlertModal' + +describe('CatToolActions.processErrors', () => { + beforeEach(() => { + global.config = {id_job: 2} + jest.clearAllMocks() + }) + + test('shows "Segment disabled" modal with refresh callback for -5 on setTranslation', () => { + const onRenderSpy = jest + .spyOn(CatToolActions, 'onRender') + .mockImplementation(() => {}) + + CatToolActions.processErrors([{code: '-5'}], 'setTranslation') + + expect(ModalsActions.showModalComponent).toHaveBeenCalledWith( + AlertModal, + expect.objectContaining({ + text: 'This segment has been disabled by the project owner.
Refresh the page to update segment status.', + buttonText: 'Refresh page', + successCallback: expect.any(Function), + }), + 'Segment disabled', + ) + + const modalProps = ModalsActions.showModalComponent.mock.calls[0][1] + modalProps.successCallback() + expect(onRenderSpy).toHaveBeenCalledTimes(1) + }) + + test('shows generic error modal when code is not -5 and not -10 on setTranslation', () => { + CatToolActions.processErrors([{code: '-1'}], 'setTranslation') + + expect(ModalsActions.showModalComponent).toHaveBeenCalledWith( + AlertModal, + expect.objectContaining({ + text: expect.stringContaining('Error in saving the translation'), + }), + 'Error', + ) + }) + + test('does NOT show generic error modal when code is -10 on setTranslation', () => { + CatToolActions.processErrors([{code: '-10'}], 'setTranslation') + + // Should not have been called with the generic error text + const calls = ModalsActions.showModalComponent.mock.calls + const genericErrorCall = calls.find( + (call) => + call[2] === 'Error' && + call[1]?.text?.includes('Error in saving the translation'), + ) + expect(genericErrorCall).toBeUndefined() + }) + + test('shows "Job canceled" modal when code is -10 and operation is not getSegments', () => { + CatToolActions.processErrors([{code: '-10'}], 'setSuggestion') + + expect(ModalsActions.showModalComponent).toHaveBeenCalledWith( + AlertModal, + expect.objectContaining({ + text: 'Job canceled or assigned to another translator', + successCallback: expect.any(Function), + }), + 'Error', + ) + }) +}) diff --git a/public/js/actions/SegmentActions.js b/public/js/actions/SegmentActions.js index 05019a0a53..931c9fd441 100644 --- a/public/js/actions/SegmentActions.js +++ b/public/js/actions/SegmentActions.js @@ -692,7 +692,8 @@ const SegmentActions = { }) } if (TextUtils.justSelecting('readonly')) return - let locked = !segment.unlocked && SegmentUtils.isIceSegment(segment) + + const locked = !segment.unlocked && SegmentUtils.isIceSegment(segment) if (locked) { ModalsActions.showModalComponent( AlertModal, @@ -706,6 +707,23 @@ const SegmentActions = { ) return } + + const isTranslationDisabled = segment?.metadata?.some( + ({meta_key, meta_value}) => + meta_key === 'translation_disabled' && meta_value === '1', + ) + + if (isTranslationDisabled) { + ModalsActions.showModalComponent( + AlertModal, + { + text: "This segment has been disabled by the project owner, so it cannot be translated.", + }, + 'Segment disabled', + ) + return + } + ModalsActions.showModalComponent(AlertModal, { text: SegmentActions.messageForClickOnReadonly(), }) diff --git a/public/js/actions/SegmentActions.test.js b/public/js/actions/SegmentActions.test.js new file mode 100644 index 0000000000..9f00089b74 --- /dev/null +++ b/public/js/actions/SegmentActions.test.js @@ -0,0 +1,222 @@ +jest.mock('../stores/AppDispatcher', () => ({ + dispatch: jest.fn(), + register: jest.fn(), +})) + +jest.mock('../stores/SegmentStore', () => ({ + getCurrentSegmentId: jest.fn(), + getSegmentByIdToJS: jest.fn(), + consecutiveCopySourceNum: [], +})) + +jest.mock('../stores/CatToolStore', () => ({ + getJobMetadata: jest.fn(), +})) + +jest.mock('../utils/segmentUtils', () => ({ + isIceSegment: jest.fn(() => false), + isReadonlySegment: jest.fn(), +})) + +jest.mock('./CatToolActions', () => ({ + addNotification: jest.fn(), + onRender: jest.fn(), +})) + +jest.mock('./ModalsActions', () => ({ + showModalComponent: jest.fn(), +})) + +jest.mock('../components/modals/AlertModal', () => 'AlertModal') +jest.mock('../components/modals/CopySourceModal', () => ({ + COPY_SOURCE_COOKIE: 'copy_source', + __esModule: true, + default: 'CopySourceModal', +})) +jest.mock('../components/modals/ConfirmMessageModal', () => 'ConfirmMessageModal') + +jest.mock('../utils/offlineUtils', () => ({ + startOfflineMode: jest.fn(), + failedConnection: jest.fn(), +})) + +jest.mock('../utils/textUtils', () => ({ + __esModule: true, + default: {justSelecting: jest.fn(() => false)}, +})) + +jest.mock('../utils/commonUtils', () => ({ + __esModule: true, + default: {dispatchCustomEvent: jest.fn()}, +})) + +jest.mock('../components/segments/utils/translationMatches', () => ({ + __esModule: true, + default: {}, +})) + +jest.mock('../components/segments/utils/DraftMatecatUtils', () => ({ + __esModule: true, + default: {}, +})) + +jest.mock('../components/header/cattol/segment_filter/segment_filter', () => ({ + __esModule: true, + default: {}, +})) + +jest.mock('../constants/SegmentConstants', () => ({})) +jest.mock('../constants/EditAreaConstants', () => ({})) +jest.mock('../constants/CatToolConstants', () => ({})) +jest.mock('../constants/Constants', () => ({ + REVISE_STEP_NUMBER: {}, + SEGMENTS_STATUS: {}, +})) + +jest.mock('../components/segments/SegmentFooter', () => ({ + TAB: {}, +})) + +jest.mock('../api/getGlossaryForSegment', () => ({ + getGlossaryForSegment: jest.fn(), +})) +jest.mock('../api/getGlossaryMatch', () => ({getGlossaryMatch: jest.fn()})) +jest.mock('../api/deleteGlossaryItem', () => ({deleteGlossaryItem: jest.fn()})) +jest.mock('../api/addGlossaryItem', () => ({addGlossaryItem: jest.fn()})) +jest.mock('../api/updateGlossaryItem', () => ({updateGlossaryItem: jest.fn()})) +jest.mock('../api/approveSegments', () => ({approveSegments: jest.fn()})) +jest.mock('../api/translateSegments', () => ({translateSegments: jest.fn()})) +jest.mock('../api/splitSegment', () => ({splitSegment: jest.fn()})) +jest.mock('../api/copyAllSourceToTarget', () => ({ + copyAllSourceToTarget: jest.fn(), +})) +jest.mock('../api/getLocalWarnings', () => ({getLocalWarnings: jest.fn()})) +jest.mock('../api/getGlossaryCheck', () => ({getGlossaryCheck: jest.fn()})) +jest.mock('../api/deleteSegmentIssue', () => ({ + deleteSegmentIssue: jest.fn(), +})) +jest.mock('../api/getSegmentsIssues', () => ({getSegmentsIssues: jest.fn()})) +jest.mock('../api/getSegmentVersionsIssues', () => ({ + getSegmentVersionsIssues: jest.fn(), +})) +jest.mock('../api/sendSegmentVersionIssueComment', () => ({ + sendSegmentVersionIssueComment: jest.fn(), +})) +jest.mock('../api/getTagProjection', () => ({getTagProjection: jest.fn()})) +jest.mock('../api/setCurrentSegment', () => ({setCurrentSegment: jest.fn()})) +jest.mock('../api/getTranslationMismatches', () => ({ + getTranslationMismatches: jest.fn(), +})) + +jest.mock('react', () => ({createElement: jest.fn()})) +jest.mock('jquery', () => jest.fn(() => ({find: jest.fn()}))) +jest.mock('lodash', () => ({ + each: jest.fn(), + forEach: jest.fn(), + isUndefined: (v) => typeof v === 'undefined', +})) +jest.mock('lodash/function', () => ({ + debounce: (fn) => fn, +})) +jest.mock('immutable', () => ({fromJS: jest.fn()})) +jest.mock('lodash/array', () => ({union: jest.fn()})) + +jest.mock('../utils/speech2text', () => ({ + __esModule: true, + default: {enabled: jest.fn(() => false)}, +})) + +import SegmentActions from './SegmentActions' +import SegmentUtils from '../utils/segmentUtils' +import ModalsActions from './ModalsActions' +import AlertModal from '../components/modals/AlertModal' + +describe('SegmentActions.handleClickOnReadOnly', () => { + beforeEach(() => { + global.config = { + id_job: 2, + project_completion_feature_enabled: false, + isReview: false, + job_completion_current_phase: 'translate', + } + + jest.clearAllMocks() + SegmentUtils.isIceSegment.mockReturnValue(false) + }) + + test('shows "Segment disabled" AlertModal when metadata has translation_disabled=1', () => { + const segment = { + unlocked: true, + metadata: [{meta_key: 'translation_disabled', meta_value: '1'}], + } + + SegmentActions.handleClickOnReadOnly(segment) + + expect(ModalsActions.showModalComponent).toHaveBeenCalledWith( + AlertModal, + { + text: 'This segment has been disabled by the project owner, so it cannot be translated.', + }, + 'Segment disabled', + ) + }) + + test('shows generic readonly AlertModal when segment is not disabled and not ICE-locked', () => { + const segment = { + unlocked: true, + metadata: [], + } + + SegmentActions.handleClickOnReadOnly(segment) + + expect(ModalsActions.showModalComponent).toHaveBeenCalledWith( + AlertModal, + expect.objectContaining({ + text: expect.any(String), + }), + ) + expect(ModalsActions.showModalComponent).not.toHaveBeenCalledWith( + AlertModal, + expect.objectContaining({ + text: 'This segment has been disabled by the project owner, so it cannot be translated.', + }), + 'Segment disabled', + ) + }) + + test('shows ICE match modal when segment is ICE-locked', () => { + SegmentUtils.isIceSegment.mockReturnValue(true) + + const segment = { + unlocked: false, + metadata: [{meta_key: 'translation_disabled', meta_value: '1'}], + } + + SegmentActions.handleClickOnReadOnly(segment) + + expect(ModalsActions.showModalComponent).toHaveBeenCalledWith( + AlertModal, + expect.objectContaining({ + text: expect.stringContaining('Segment is locked'), + }), + 'Ice Matches', + ) + }) + + test('does not show disabled modal when translation_disabled is 0', () => { + const segment = { + unlocked: true, + metadata: [{meta_key: 'translation_disabled', meta_value: '0'}], + } + + SegmentActions.handleClickOnReadOnly(segment) + + expect(ModalsActions.showModalComponent).not.toHaveBeenCalledWith( + AlertModal, + expect.objectContaining({ + text: 'This segment has been disabled by the project owner, so it cannot be translated.', + }), + 'Segment disabled', + ) + }) +}) diff --git a/public/js/components/segments/Segment.js b/public/js/components/segments/Segment.js index de804cdc1c..205571f8cc 100644 --- a/public/js/components/segments/Segment.js +++ b/public/js/components/segments/Segment.js @@ -1,4 +1,4 @@ -import {forEach, isUndefined} from 'lodash' +import {forEach, isEqual, isUndefined} from 'lodash' import {fromJS} from 'immutable' import React from 'react' import {union} from 'lodash/array' @@ -691,7 +691,16 @@ class Segment extends React.Component { } return null } - componentDidUpdate() {} + componentDidUpdate(prevProps) { + if (!isEqual(prevProps.segment, this.props.segment)) { + const readonly = SegmentUtils.isReadonlySegment(this.props.segment) + if (readonly !== this.state.readonly) { + this.setState({ + readonly, + }) + } + } + } render() { let job_marker = '' diff --git a/public/js/components/segments/Segment.test.js b/public/js/components/segments/Segment.test.js new file mode 100644 index 0000000000..e2ebfa3f97 --- /dev/null +++ b/public/js/components/segments/Segment.test.js @@ -0,0 +1,236 @@ +import React from 'react' + +const mockIsReadonlySegment = jest.fn() + +jest.mock('../../stores/SegmentStore', () => ({ + getCurrentSegmentId: jest.fn(), + getSegmentByIdToJS: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + segmentHasIssues: jest.fn(() => false), +})) + +jest.mock('../../actions/SegmentActions', () => ({ + saveSegmentBeforeClose: jest.fn(), + localStorageCommentsClosed: 'comments_closed', +})) + +jest.mock('../../stores/CatToolStore', () => ({ + addListener: jest.fn(), + removeListener: jest.fn(), +})) + +jest.mock('../../stores/CommentsStore', () => ({ + db: {}, + addListener: jest.fn(), + removeListener: jest.fn(), +})) + +jest.mock('../../constants/SegmentConstants', () => ({ + ADD_SEGMENT_CLASS: 'ADD_SEGMENT_CLASS', + REMOVE_SEGMENT_CLASS: 'REMOVE_SEGMENT_CLASS', + SET_SEGMENT_STATUS: 'SET_SEGMENT_STATUS', +})) +jest.mock('../../constants/CatToolConstants', () => ({ + CLIENT_RECONNECTION: 'CLIENT_RECONNECTION', +})) +jest.mock('../../constants/Constants', () => ({ + SEGMENTS_STATUS: {}, +})) + +jest.mock('../../actions/ModalsActions', () => ({ + showModalComponent: jest.fn(), +})) + +jest.mock('../../utils/segmentUtils', () => ({ + __esModule: true, + default: { + isReadonlySegment: (...args) => mockIsReadonlySegment(...args), + isSecondPassLockedSegment: jest.fn(() => false), + isUnlockedSegment: jest.fn(() => false), + isIceSegment: jest.fn(() => false), + }, +})) + +jest.mock('../../utils/speech2text', () => ({ + __esModule: true, + default: {enabled: jest.fn(() => false)}, +})) + +jest.mock('../../utils/shortcuts', () => ({ + Shortcuts: {cattol: {events: {}}}, +})) + +jest.mock('./SegmentContext', () => { + const ReactLib = require('react') + return {SegmentContext: ReactLib.createContext({})} +}) + +jest.mock('../common/ApplicationWrapper/ApplicationWrapperContext', () => { + const ReactLib = require('react') + return {ApplicationWrapperContext: ReactLib.createContext({})} +}) + +jest.mock('./SegmentHeader', () => () => null) +jest.mock('./SegmentFooter', () => () => null) +jest.mock('./SegmentBody', () => () => null) +jest.mock('./SegmentsCommentsIcon', () => () => null) +jest.mock('./SegmentCommentsContainer', () => () => null) +jest.mock('../review_extended/ReviewExtendedPanel', () => () => null) +jest.mock('../review/TranslationIssuesSideButton', () => () => null) +jest.mock('./SegmentQAIcon', () => ({SegmentQAIcon: () => null})) + +jest.mock('../header/cattol/segment_filter/segment_filter', () => ({ + __esModule: true, + default: {enabled: jest.fn(() => false)}, +})) +jest.mock('../header/cattol/search/searchUtils', () => ({ + __esModule: true, + default: {getHighlightedElementData: jest.fn(() => null)}, +})) + +jest.mock('./utils/DraftMatecatUtils', () => ({ + __esModule: true, + default: { + checkXliffTagsInText: jest.fn(() => false), + removeTagsFromText: jest.fn(() => 'text'), + }, +})) + +jest.mock('lodash/array', () => ({ + union: jest.fn((...args) => [].concat(...args)), +})) + +jest.mock('immutable', () => { + const createImmutable = (value) => ({ + value, + equals: (other) => + JSON.stringify(value) === JSON.stringify(other?.value ?? other), + toJS: () => value, + }) + return { + __esModule: true, + fromJS: jest.fn((v) => createImmutable(v)), + } +}) + +jest.mock('../modals/ConfirmMessageModal', () => 'ConfirmMessageModal') + +import Segment from './Segment' + +function makeSegment(overrides = {}) { + return { + sid: '10', + original_sid: '10', + segment: 'Source segment', + translation: 'Translated segment', + status: 'NEW', + warnings: {}, + metadata: [], + match_type: 'NO_MATCH', + autopropagated_from: 0, + repetitions_in_chunk: 1, + split_group: [], + opened: false, + unlocked: false, + readonly: false, + ice_locked: false, + tagged: false, + ...overrides, + } +} + +describe('Segment componentDidUpdate readonly re-evaluation', () => { + let instance + let setStateCalls + + beforeEach(() => { + window.React = React + window.config = { + id_job: 2, + basepath: '/', + password: 'test', + isReview: false, + project_completion_feature_enabled: false, + segmentFilterEnabled: false, + source_rfc: 'en-US', + target_rfc: 'it-IT', + tag_projection_languages: '{}', + } + + mockIsReadonlySegment.mockReset() + setStateCalls = [] + + const segment = makeSegment() + instance = new Segment({ + segment, + segImmutable: segment, + isReview: false, + guessTagActive: false, + speechToTextActive: false, + files: {}, + }) + // Override setState to capture calls since React's updater is a no-op on unmounted instances + instance.setState = (arg) => setStateCalls.push(arg) + }) + + test('calls setState with readonly=true when segment changes and becomes disabled', () => { + const prevSegment = makeSegment() + const newSegment = makeSegment({ + metadata: [{meta_key: 'translation_disabled', meta_value: '1'}], + }) + + instance.props = {...instance.props, segment: newSegment} + instance.state = {...instance.state, readonly: false} + mockIsReadonlySegment.mockReturnValue(true) + + instance.componentDidUpdate({...instance.props, segment: prevSegment}) + + expect(mockIsReadonlySegment).toHaveBeenCalledWith(newSegment) + expect(setStateCalls).toContainEqual({readonly: true}) + }) + + test('calls setState with readonly=false when segment changes and becomes enabled', () => { + const prevSegment = makeSegment({ + metadata: [{meta_key: 'translation_disabled', meta_value: '1'}], + }) + const newSegment = makeSegment({metadata: []}) + + instance.props = {...instance.props, segment: newSegment} + instance.state = {...instance.state, readonly: true} + mockIsReadonlySegment.mockReturnValue(false) + + instance.componentDidUpdate({...instance.props, segment: prevSegment}) + + expect(mockIsReadonlySegment).toHaveBeenCalledWith(newSegment) + expect(setStateCalls).toContainEqual({readonly: false}) + }) + + test('does not call setState when segment prop is unchanged', () => { + const segment = makeSegment() + + instance.props = {...instance.props, segment} + instance.state = {...instance.state, readonly: false} + mockIsReadonlySegment.mockReturnValue(false) + + instance.componentDidUpdate({...instance.props, segment}) + + const readonlyCalls = setStateCalls.filter((call) => 'readonly' in call) + expect(readonlyCalls).toHaveLength(0) + }) + + test('does not call setState when readonly value has not changed', () => { + const prevSegment = makeSegment() + const newSegment = makeSegment({translation: 'Different translation'}) + + instance.props = {...instance.props, segment: newSegment} + instance.state = {...instance.state, readonly: false} + mockIsReadonlySegment.mockReturnValue(false) + + instance.componentDidUpdate({...instance.props, segment: prevSegment}) + + expect(mockIsReadonlySegment).toHaveBeenCalledWith(newSegment) + const readonlyCalls = setStateCalls.filter((call) => 'readonly' in call) + expect(readonlyCalls).toHaveLength(0) + }) +}) diff --git a/public/js/utils/segmentUtils.js b/public/js/utils/segmentUtils.js index eccf102e23..4f6752b31a 100644 --- a/public/js/utils/segmentUtils.js +++ b/public/js/utils/segmentUtils.js @@ -264,7 +264,13 @@ const SegmentUtils = { config.project_completion_feature_enabled && !config.isReview && config.job_completion_current_phase === 'revise' - return projectCompletionCheck || segment.readonly + + const isTranslationDisabled = segment?.metadata?.some( + ({meta_key, meta_value}) => + meta_key === 'translation_disabled' && meta_value === '1', + ) + + return projectCompletionCheck || segment.readonly || isTranslationDisabled }, getRelativeTransUnitCharactersCounter: ({ sid, diff --git a/public/js/utils/segmentUtils.test.js b/public/js/utils/segmentUtils.test.js new file mode 100644 index 0000000000..2c37f3c33c --- /dev/null +++ b/public/js/utils/segmentUtils.test.js @@ -0,0 +1,75 @@ +jest.mock('../stores/SegmentStore', () => ({})) +jest.mock('../components/segments/utils/DraftMatecatUtils', () => ({})) +jest.mock('../constants/Constants', () => ({})) +jest.mock('../stores/UserStore', () => ({})) + +import segmentUtils from './segmentUtils' + +describe('segmentUtils.isReadonlySegment', () => { + beforeEach(() => { + global.config = { + id_job: 2, + project_completion_feature_enabled: false, + isReview: false, + job_completion_current_phase: '', + } + }) + + test('returns true when metadata contains translation_disabled=1', () => { + const segment = { + readonly: false, + metadata: [{meta_key: 'translation_disabled', meta_value: '1'}], + } + + expect(segmentUtils.isReadonlySegment(segment)).toBe(true) + }) + + test('returns false when metadata is an empty array', () => { + const segment = { + readonly: false, + metadata: [], + } + + expect(segmentUtils.isReadonlySegment(segment)).toBe(false) + }) + + test('returns false when metadata does not contain translation_disabled key', () => { + const segment = { + readonly: false, + metadata: [{meta_key: 'foo', meta_value: '1'}], + } + + expect(segmentUtils.isReadonlySegment(segment)).toBe(false) + }) + + test('returns false when translation_disabled is present but value is 0', () => { + const segment = { + readonly: false, + metadata: [{meta_key: 'translation_disabled', meta_value: '0'}], + } + + expect(segmentUtils.isReadonlySegment(segment)).toBe(false) + }) + + test('returns true when segment.readonly is true', () => { + const segment = { + readonly: true, + metadata: [], + } + + expect(segmentUtils.isReadonlySegment(segment)).toBe(true) + }) + + test('returns true when project completion is enabled and current phase is revise', () => { + global.config.project_completion_feature_enabled = true + global.config.isReview = false + global.config.job_completion_current_phase = 'revise' + + const segment = { + readonly: false, + metadata: [], + } + + expect(segmentUtils.isReadonlySegment(segment)).toBe(true) + }) +}) diff --git a/tests/unit/Controllers/CancelRequestControllerTest.php b/tests/unit/Controllers/CancelRequestControllerTest.php new file mode 100644 index 0000000000..88b376668c --- /dev/null +++ b/tests/unit/Controllers/CancelRequestControllerTest.php @@ -0,0 +1,913 @@ +getProperty('request')->setValue($this, $request); + $ref->getProperty('response')->setValue($this, $response); + } + + public function enableRequest(): void + { + // Skip performChecks, go straight to validation logic + $rawIdJob = $this->request->param('id_job'); + $rawIdSegment = $this->request->param('id_segment'); + + $id_job = filter_var($rawIdJob, FILTER_VALIDATE_INT); + $id_segment = filter_var($rawIdSegment, FILTER_VALIDATE_INT); + + if ($id_job === false || $id_segment === false) { + $this->response->code(400); + $this->response->header('Content-Type', 'application/json'); + $this->response->body(json_encode([ + 'errors' => [ + [ + 'code' => 400, + 'message' => 'Invalid id_job or id_segment', + ], + ], + ])); + + return; + } + + if ($this->isSegmentDisabled($id_job, $id_segment)) { + $this->destroySegmentDisabledCache($id_job, $id_segment); + } + + $this->response->json([ + 'id_segment' => $id_segment, + ]); + } + + public function cancelRequest(): void + { + // Skip performChecks, go straight to disable logic + $id_job = $this->request->param('id_job'); + $id_segment = $this->request->param('id_segment'); + + if (!$this->isSegmentDisabled($id_job, $id_segment)) { + $this->saveSegmentDisabledInCache($id_job, $id_segment); + } + + $this->response->json([ + 'id_segment' => $id_segment, + ]); + } + + protected function isSegmentDisabled(int $id_job, int $id_segment): bool + { + return $this->segmentDisabledFlag; + } + + protected function saveSegmentDisabledInCache(int $id_job, int $id_segment): void + { + $this->savedDisabledSegments[] = ['id_job' => $id_job, 'id_segment' => $id_segment]; + } + + protected function destroySegmentDisabledCache(int $id_job, int $id_segment): void + { + $this->destroySegmentDisabledCacheCalled = true; + } +} + +#[AllowMockObjectsWithoutExpectations] +class CancelRequestControllerTest extends AbstractTest +{ + private Request|MockObject $request; + private Response|MockObject $response; + + protected function setUp(): void + { + parent::setUp(); + $this->request = $this->createStub(Request::class); + $this->response = $this->createMock(Response::class); + } + + // ─── enableRequest tests ───────────────────────────────────────── + + #[Test] + public function enableRequestReturnsJsonWithIdSegment(): void + { + $controller = $this->createControllerWithBypassedPerformChecks(); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $this->response->expects($this->once()) + ->method('json') + ->with(['id_segment' => 42]); + + $controller->enableRequest(); + } + + #[Test] + public function enableRequestReturnsBadRequestForInvalidIdJob(): void + { + $controller = $this->createControllerWithBypassedPerformChecks(); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 'not_a_number'], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $this->response->expects($this->once()) + ->method('code') + ->with(400); + + $this->response->expects($this->once()) + ->method('header') + ->with('Content-Type', 'application/json'); + + $this->response->expects($this->once()) + ->method('body') + ->with($this->callback(function ($body) { + $decoded = json_decode($body, true); + return $decoded['errors'][0]['code'] === 400 + && $decoded['errors'][0]['message'] === 'Invalid id_job or id_segment'; + })); + + $controller->enableRequest(); + } + + #[Test] + public function enableRequestReturnsBadRequestForInvalidIdSegment(): void + { + $controller = $this->createControllerWithBypassedPerformChecks(); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 'invalid'], + ]); + + $this->response->expects($this->once()) + ->method('code') + ->with(400); + + $controller->enableRequest(); + } + + #[Test] + public function enableRequestReturnsBadRequestWhenBothIdsAreInvalid(): void + { + $controller = $this->createControllerWithBypassedPerformChecks(); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 'abc'], + ['password', null, 'abc123'], + ['id_segment', null, 'xyz'], + ]); + + $this->response->expects($this->once()) + ->method('code') + ->with(400); + + $controller->enableRequest(); + } + + #[Test] + public function enableRequestSucceedsWithNegativeIdJob(): void + { + $controller = $this->createControllerWithBypassedPerformChecks(); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, -1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + // filter_var(-1, FILTER_VALIDATE_INT) returns -1 (valid int, truthy) + $this->response->expects($this->once()) + ->method('json') + ->with(['id_segment' => 42]); + + $controller->enableRequest(); + } + + #[Test] + public function enableRequestSucceedsWithZeroIdJob(): void + { + $controller = $this->createControllerWithBypassedPerformChecks(); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 0], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + // filter_var(0, FILTER_VALIDATE_INT) returns 0 (valid int, but falsy in PHP loose comparison) + // Actually returns int(0), which is !== false, so it passes the check + $this->response->expects($this->once()) + ->method('json') + ->with(['id_segment' => 42]); + + $controller->enableRequest(); + } + + #[Test] + public function enableRequestReturnsBadRequestForNullIdSegment(): void + { + $controller = $this->createControllerWithBypassedPerformChecks(); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, null], + ]); + + $this->response->expects($this->once()) + ->method('code') + ->with(400); + + $controller->enableRequest(); + } + + #[Test] + public function enableRequestReturnsBadRequestForFloatIdJob(): void + { + $controller = $this->createControllerWithBypassedPerformChecks(); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, '1.5'], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $this->response->expects($this->once()) + ->method('code') + ->with(400); + + $controller->enableRequest(); + } + + #[Test] + public function enableRequestWithLargeIntegerParams(): void + { + $controller = $this->createControllerWithBypassedPerformChecks(); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 999999999], + ['password', null, 'pass'], + ['id_segment', null, 888888888], + ]); + + $this->response->expects($this->once()) + ->method('json') + ->with(['id_segment' => 888888888]); + + $controller->enableRequest(); + } + + #[Test] + public function enableRequestWithStringIntegerParams(): void + { + $controller = $this->createControllerWithBypassedPerformChecks(); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, '123'], + ['password', null, 'abc123'], + ['id_segment', null, '456'], + ]); + + $this->response->expects($this->once()) + ->method('json') + ->with(['id_segment' => 456]); + + $controller->enableRequest(); + } + + #[Test] + public function enableRequestReturnsBadRequestForEmptyStringIdJob(): void + { + $controller = $this->createControllerWithBypassedPerformChecks(); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, ''], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $this->response->expects($this->once()) + ->method('code') + ->with(400); + + $controller->enableRequest(); + } + + #[Test] + public function enableRequestCallsDestroySegmentDisabledCacheWhenSegmentIsDisabled(): void + { + $controller = $this->createControllerWithBypassedPerformChecks(segmentDisabled: true); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $controller->enableRequest(); + + $this->assertTrue($controller->destroySegmentDisabledCacheCalled); + } + + #[Test] + public function enableRequestDoesNotCallDestroyWhenSegmentIsNotDisabled(): void + { + $controller = $this->createControllerWithBypassedPerformChecks(segmentDisabled: false); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $controller->enableRequest(); + + $this->assertFalse($controller->destroySegmentDisabledCacheCalled); + } + + // ─── cancelRequest tests ───────────────────────────────────────── + + #[Test] + public function cancelRequestReturnsJsonWithIdSegment(): void + { + $controller = $this->createControllerWithBypassedPerformChecks(); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $this->response->expects($this->once()) + ->method('json') + ->with(['id_segment' => 42]); + + $controller->cancelRequest(); + } + + #[Test] + public function cancelRequestSkipsDisableWhenSegmentAlreadyDisabled(): void + { + $controller = $this->createControllerWithBypassedPerformChecks(segmentDisabled: true); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $this->response->expects($this->once()) + ->method('json') + ->with(['id_segment' => 42]); + + $controller->cancelRequest(); + + $this->assertEmpty($controller->savedDisabledSegments); + } + + #[Test] + public function cancelRequestSavesDisabledCacheWhenNotAlreadyDisabled(): void + { + $controller = $this->createControllerWithBypassedPerformChecks(segmentDisabled: false); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $controller->cancelRequest(); + + $this->assertCount(1, $controller->savedDisabledSegments); + $this->assertEquals(['id_job' => 1, 'id_segment' => 42], $controller->savedDisabledSegments[0]); + } + + #[Test] + public function cancelRequestWithDifferentSegmentId(): void + { + $controller = $this->createControllerWithBypassedPerformChecks(); + + $this->request->method('param')->willReturnCallback(function ($key) { + return match ($key) { + 'id_job' => 10, + 'password' => 'pwd123', + 'id_segment' => 55, + default => null, + }; + }); + + $this->response->expects($this->once()) + ->method('json') + ->with(['id_segment' => 55]); + + $controller->cancelRequest(); + } + + // ─── performChecks tests ───────────────────────────────────────── + + #[Test] + public function performChecksThrowsNotFoundExceptionWhenJobNotFound(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Job not found.'); + + $controller = $this->createControllerWithPartialMock(jobReturn: null); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $controller->cancelRequest(); + } + + #[Test] + public function performChecksThrowsNotFoundExceptionWhenSegmentNotFound(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Segment not found'); + + $jobStruct = $this->createStub(JobStruct::class); + $controller = $this->createControllerWithPartialMock(jobReturn: $jobStruct, segmentReturn: null); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $controller->cancelRequest(); + } + + #[Test] + public function performChecksThrowsNotFoundExceptionWhenTeamNotFound(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Team not found'); + + $projectStruct = $this->createStub(ProjectStruct::class); + $projectStruct->method('getTeam')->willReturn(null); + + $jobStruct = $this->createStub(JobStruct::class); + $jobStruct->method('getProject')->willReturn($projectStruct); + + $segmentTranslation = new SegmentTranslationStruct(); + $segmentTranslation->status = 'NEW'; + + $controller = $this->createControllerWithPartialMock( + jobReturn: $jobStruct, + segmentReturn: $segmentTranslation, + ); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $controller->cancelRequest(); + } + + #[Test] + public function performChecksThrowsExceptionWhenUserIsNotPartOfTeam(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('User is not part of the team'); + + $teamStruct = $this->createStub(TeamStruct::class); + $teamStruct->created_by = 999; + $teamStruct->method('hasUser')->willReturn(false); + + $projectStruct = $this->createStub(ProjectStruct::class); + $projectStruct->method('getTeam')->willReturn($teamStruct); + + $jobStruct = $this->createStub(JobStruct::class); + $jobStruct->method('getProject')->willReturn($projectStruct); + + $segmentTranslation = new SegmentTranslationStruct(); + $segmentTranslation->status = 'NEW'; + + $user = $this->createStub(UserStruct::class); + $user->uid = 123; + $user->email = 'test@example.com'; + + $controller = $this->createControllerWithPartialMock( + jobReturn: $jobStruct, + segmentReturn: $segmentTranslation, + user: $user, + ); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $controller->cancelRequest(); + } + + #[Test] + public function performChecksPassesWhenUserIsTeamMemberButNotOwner(): void + { + // When user is a team member but not the creator, the code falls through + // to the segment status check (no "not owner" exception is thrown) + $this->expectException(Exception::class); + $this->expectExceptionMessage('Segment is not in "new" status and cannot be disabled'); + + $teamStruct = $this->createStub(TeamStruct::class); + $teamStruct->created_by = 999; + $teamStruct->method('hasUser')->willReturn(true); + + $projectStruct = $this->createStub(ProjectStruct::class); + $projectStruct->method('getTeam')->willReturn($teamStruct); + + $jobStruct = $this->createStub(JobStruct::class); + $jobStruct->method('getProject')->willReturn($projectStruct); + + $segmentTranslation = new SegmentTranslationStruct(); + $segmentTranslation->status = 'TRANSLATED'; + + $user = $this->createStub(UserStruct::class); + $user->uid = 123; + $user->email = 'test@example.com'; + + $controller = $this->createControllerWithPartialMock( + jobReturn: $jobStruct, + segmentReturn: $segmentTranslation, + user: $user, + ); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $controller->cancelRequest(); + } + + #[Test] + public function performChecksThrowsExceptionWhenSegmentStatusIsTranslated(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Segment is not in "new" status and cannot be disabled'); + + $controller = $this->buildControllerWithSegmentStatus('TRANSLATED'); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $controller->cancelRequest(); + } + + #[Test] + public function performChecksThrowsExceptionWhenSegmentStatusIsApproved(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Segment is not in "new" status and cannot be disabled'); + + $controller = $this->buildControllerWithSegmentStatus('APPROVED'); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $controller->cancelRequest(); + } + + #[Test] + public function performChecksThrowsExceptionWhenSegmentStatusIsDraft(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Segment is not in "new" status and cannot be disabled'); + + $controller = $this->buildControllerWithSegmentStatus('DRAFT'); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $controller->cancelRequest(); + } + + #[Test] + public function performChecksThrowsExceptionWhenSegmentStatusIsRejected(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Segment is not in "new" status and cannot be disabled'); + + $controller = $this->buildControllerWithSegmentStatus('REJECTED'); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $controller->cancelRequest(); + } + + #[Test] + public function performChecksReturnsEarlyOnRateLimitForIp(): void + { + $rateLimitedResponse = $this->createStub(Response::class); + $rateLimitedResponse->method('code')->willReturn(429); + + $controller = $this->createControllerWithPartialMock(rateLimitResponseIp: $rateLimitedResponse); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + // Should not throw and not call json — the response is replaced with 429 + $this->response->expects($this->never())->method('json'); + + $controller->cancelRequest(); + } + + #[Test] + public function performChecksReturnsEarlyOnRateLimitForEmail(): void + { + $rateLimitedResponse = $this->createStub(Response::class); + $rateLimitedResponse->method('code')->willReturn(429); + + $controller = $this->createControllerWithPartialMock(rateLimitResponseEmail: $rateLimitedResponse); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $this->response->expects($this->never())->method('json'); + + $controller->cancelRequest(); + } + + #[Test] + public function performChecksPassesWhenUserIsTeamOwnerAndSegmentIsNew(): void + { + $teamStruct = $this->createStub(TeamStruct::class); + $teamStruct->created_by = 123; + + $projectStruct = $this->createStub(ProjectStruct::class); + $projectStruct->method('getTeam')->willReturn($teamStruct); + + $jobStruct = $this->createStub(JobStruct::class); + $jobStruct->method('getProject')->willReturn($projectStruct); + + $segmentTranslation = new SegmentTranslationStruct(); + $segmentTranslation->status = 'NEW'; + + $user = $this->createStub(UserStruct::class); + $user->uid = 123; + $user->email = 'owner@example.com'; + + $controller = $this->createControllerWithPartialMock( + jobReturn: $jobStruct, + segmentReturn: $segmentTranslation, + user: $user, + ); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + // Mock response->code() to return 200 (not 429) so enableRequest proceeds + $this->response->method('code')->willReturn(200); + $this->response->expects($this->once()) + ->method('json') + ->with(['id_segment' => 42]); + + $controller->enableRequest(); + } + + #[Test] + public function performChecksUsesBlankEmailWhenUserEmailIsNull(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Job not found.'); + + $user = $this->createStub(UserStruct::class); + $user->uid = 123; + $user->email = null; + + $controller = $this->createControllerWithPartialMock( + jobReturn: null, + user: $user, + ); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $controller->cancelRequest(); + } + + #[Test] + public function performChecksIncrementsRateLimitCounterWhenJobNotFound(): void + { + $user = $this->createStub(UserStruct::class); + $user->uid = 123; + $user->email = 'test@example.com'; + + $controller = $this->getMockBuilder(CancelRequestController::class) + ->disableOriginalConstructor() + ->onlyMethods([ + 'getJob', + 'checkRateLimitResponse', + 'incrementRateLimitCounter', + 'getUser', + 'isSegmentDisabled', + 'saveSegmentDisabledInCache', + 'findSegmentTranslation', + 'destroySegmentDisabledCache', + ]) + ->getMock(); + + $ref = new ReflectionClass(CancelRequestController::class); + $ref->getProperty('request')->setValue($controller, $this->request); + $ref->getProperty('response')->setValue($controller, $this->response); + $ref->getProperty('user')->setValue($controller, $user); + + $controller->method('getUser')->willReturn($user); + $controller->method('getJob')->willReturn(null); + $controller->method('checkRateLimitResponse')->willReturn(null); + $controller->method('isSegmentDisabled')->willReturn(false); + $controller->method('saveSegmentDisabledInCache'); + $controller->method('findSegmentTranslation')->willReturn(null); + $controller->method('destroySegmentDisabledCache'); + + $controller->expects($this->exactly(2)) + ->method('incrementRateLimitCounter'); + + $this->request->method('param')->willReturnMap([ + ['id_job', null, 1], + ['password', null, 'abc123'], + ['id_segment', null, 42], + ]); + + $this->expectException(NotFoundException::class); + $controller->cancelRequest(); + } + + // ─── Helpers ───────────────────────────────────────────────────── + + private function buildControllerWithSegmentStatus(string $status): CancelRequestController + { + $teamStruct = $this->createStub(TeamStruct::class); + $teamStruct->created_by = 123; + + $projectStruct = $this->createStub(ProjectStruct::class); + $projectStruct->method('getTeam')->willReturn($teamStruct); + + $jobStruct = $this->createStub(JobStruct::class); + $jobStruct->method('getProject')->willReturn($projectStruct); + + $segmentTranslation = new SegmentTranslationStruct(); + $segmentTranslation->status = $status; + + $user = $this->createStub(UserStruct::class); + $user->uid = 123; + $user->email = 'test@example.com'; + + return $this->createControllerWithPartialMock( + jobReturn: $jobStruct, + segmentReturn: $segmentTranslation, + user: $user, + ); + } + + private function createControllerWithBypassedPerformChecks(bool $segmentDisabled = false): TestableCancelRequestController + { + $controller = new TestableCancelRequestController(); + $controller->segmentDisabledFlag = $segmentDisabled; + $controller->initWith($this->request, $this->response); + + return $controller; + } + + private function createControllerWithPartialMock( + mixed $jobReturn = 'NOT_SET', + mixed $segmentReturn = 'NOT_SET', + ?UserStruct $user = null, + ?Response $rateLimitResponseIp = null, + ?Response $rateLimitResponseEmail = null, + ): CancelRequestController { + $controller = $this->getMockBuilder(CancelRequestController::class) + ->disableOriginalConstructor() + ->onlyMethods([ + 'getJob', + 'checkRateLimitResponse', + 'incrementRateLimitCounter', + 'getUser', + 'isSegmentDisabled', + 'saveSegmentDisabledInCache', + 'findSegmentTranslation', + 'destroySegmentDisabledCache', + ]) + ->getMock(); + + $ref = new ReflectionClass(CancelRequestController::class); + + $ref->getProperty('request')->setValue($controller, $this->request); + $ref->getProperty('response')->setValue($controller, $this->response); + + if ($user === null) { + $user = $this->createStub(UserStruct::class); + $user->email = 'test@example.com'; + $user->uid = 123; + } + + $ref->getProperty('user')->setValue($controller, $user); + + $controller->method('getUser')->willReturn($user); + + if ($jobReturn === 'NOT_SET') { + $controller->method('getJob')->willReturn(null); + } else { + $controller->method('getJob')->willReturn($jobReturn); + } + + if ($segmentReturn === 'NOT_SET') { + $controller->method('findSegmentTranslation')->willReturn(null); + } else { + $controller->method('findSegmentTranslation')->willReturn($segmentReturn); + } + + // Rate limit mocking — order in code: email first, then IP + $callIndex = 0; + $controller->method('checkRateLimitResponse') + ->willReturnCallback(function () use (&$callIndex, $rateLimitResponseIp, $rateLimitResponseEmail) { + $callIndex++; + if ($callIndex === 1) { + return $rateLimitResponseEmail; + } + if ($callIndex === 2) { + return $rateLimitResponseIp; + } + return null; + }); + + $controller->method('incrementRateLimitCounter'); + $controller->method('isSegmentDisabled')->willReturn(false); + $controller->method('saveSegmentDisabledInCache'); + $controller->method('destroySegmentDisabledCache'); + + return $controller; + } +} + diff --git a/tests/unit/Traits/RateLimiterTraitTest.php b/tests/unit/Traits/RateLimiterTraitTest.php new file mode 100644 index 0000000000..da0a94cc19 --- /dev/null +++ b/tests/unit/Traits/RateLimiterTraitTest.php @@ -0,0 +1,451 @@ +calls[] = ['method' => $commandID, 'args' => $arguments]; + + return match (strtolower($commandID)) { + 'get' => $this->store[$arguments[0]] ?? null, + 'set' => $this->doSet($arguments[0], $arguments[1]), + 'incr' => $this->doIncr($arguments[0]), + 'expire' => $this->doExpire($arguments[0], $arguments[1]), + 'ttl' => $this->ttls[$arguments[0]] ?? -1, + 'setex' => $this->doSetex($arguments[0], $arguments[1], $arguments[2]), + 'del' => $this->doDel($arguments[0]), + 'hget' => $this->hashStore[$arguments[0]][$arguments[1]] ?? null, + 'hset' => $this->doHset($arguments[0], $arguments[1], $arguments[2]), + 'hdel' => $this->doHdel($arguments[0], $arguments[1]), + default => null, + }; + } + + private function doSet($key, $value): void + { + $this->store[$key] = $value; + } + + private function doIncr($key): int + { + $this->store[$key] = ($this->store[$key] ?? 0) + 1; + return $this->store[$key]; + } + + private function doExpire($key, $ttl): bool + { + $this->ttls[$key] = $ttl; + return true; + } + + private function doSetex($key, $ttl, $value): void + { + $this->store[$key] = $value; + $this->ttls[$key] = $ttl; + } + + private function doDel($key): int + { + unset($this->store[$key], $this->ttls[$key]); + return 1; + } + + public function getStore(): array + { + return $this->store; + } + + public function setStoreValue(string $key, mixed $value): void + { + $this->store[$key] = $value; + } + + public function setTtlValue(string $key, int $ttl): void + { + $this->ttls[$key] = $ttl; + } + + public function setHashValue(string $keyMap, string $field, mixed $value): void + { + $this->hashStore[$keyMap][$field] = $value; + } + + public function getHashStore(): array + { + return $this->hashStore; + } + + private function doHset($key, $field, $value): int + { + $this->hashStore[$key][$field] = $value; + return 1; + } + + private function doHdel($key, $fields): int + { + $count = 0; + foreach ((array)$fields as $field) { + if (isset($this->hashStore[$key][$field])) { + unset($this->hashStore[$key][$field]); + $count++; + } + } + return $count; + } +} + +/** + * Concrete class that uses RateLimiterTrait for testing. + * Overrides the private getRedis() by redefining it in the class. + */ +class RateLimiterTraitConsumer +{ + use RateLimiterTrait; + + private FakeRedisClient $fakeRedis; + + public function __construct(FakeRedisClient $redis) + { + $this->fakeRedis = $redis; + } + + private function getRedis(): Client + { + return $this->fakeRedis; + } + + public function publicGetKey(string $identifier, string $route): string + { + return md5($identifier . $route); + } + + public function publicGetTtl(): int + { + $date = new DateTime(); + $ttl = 60 - $date->format("s"); + return 60 + (int)$ttl; + } +} + +#[AllowMockObjectsWithoutExpectations] +class RateLimiterTraitTest extends AbstractTest +{ + private RateLimiterTraitConsumer $consumer; + private FakeRedisClient $redis; + + protected function setUp(): void + { + parent::setUp(); + $this->redis = new FakeRedisClient(); + $this->consumer = new RateLimiterTraitConsumer($this->redis); + } + + // ─── checkRateLimitResponse tests ──────────────────────────────── + + #[Test] + public function checkRateLimitResponseReturnsNullWhenUnderLimit(): void + { + $response = $this->createStub(Response::class); + $key = md5('user@test.com' . '/api/route'); + $this->redis->setStoreValue($key, 3); + + $result = $this->consumer->checkRateLimitResponse($response, 'user@test.com', '/api/route', 10); + + $this->assertNull($result); + } + + #[Test] + public function checkRateLimitResponseReturnsNullWhenKeyDoesNotExist(): void + { + $response = $this->createStub(Response::class); + + $result = $this->consumer->checkRateLimitResponse($response, 'user@test.com', '/api/route', 10); + + $this->assertNull($result); + } + + #[Test] + public function checkRateLimitResponseReturnsNullWhenExactlyAtLimit(): void + { + $response = $this->createStub(Response::class); + $key = md5('user@test.com' . '/api/route'); + $this->redis->setStoreValue($key, 10); + + $result = $this->consumer->checkRateLimitResponse($response, 'user@test.com', '/api/route', 10); + + $this->assertNull($result); + } + + #[Test] + public function checkRateLimitResponseReturns429WhenOverLimit(): void + { + $response = $this->createMock(Response::class); + $key = md5('user@test.com' . '/api/route'); + $this->redis->setStoreValue($key, 11); + $this->redis->setTtlValue($key, 45); + + $response->expects($this->once())->method('code')->with(429); + $response->expects($this->once())->method('header')->with('Retry-After', 45); + + $result = $this->consumer->checkRateLimitResponse($response, 'user@test.com', '/api/route', 10); + + $this->assertSame($response, $result); + } + + #[Test] + public function checkRateLimitResponseSetsRetryAfterHeader(): void + { + $response = $this->createMock(Response::class); + $key = md5('user@test.com' . '/api/route'); + $this->redis->setStoreValue($key, 6); + $this->redis->setTtlValue($key, 90); + + $response->expects($this->once())->method('header')->with('Retry-After', 90); + + $this->consumer->checkRateLimitResponse($response, 'user@test.com', '/api/route', 5); + } + + #[Test] + public function checkRateLimitResponseResetsTtlAsPenalty(): void + { + $response = $this->createStub(Response::class); + $key = md5('user@test.com' . '/api/route'); + $this->redis->setStoreValue($key, 20); + $this->redis->setTtlValue($key, 30); + + $this->consumer->checkRateLimitResponse($response, 'user@test.com', '/api/route', 5); + + // Check that expire was called with the TTL value (penalty reset) + $expireCalls = array_filter($this->redis->calls, fn($c) => $c['method'] === 'expire'); + $this->assertNotEmpty($expireCalls); + $lastExpire = end($expireCalls); + $this->assertGreaterThan(60, $lastExpire['args'][1]); + } + + #[Test] + public function checkRateLimitResponseUsesDefaultMaxRetriesOf10(): void + { + $response = $this->createMock(Response::class); + $key = md5('id' . '/route'); + $this->redis->setStoreValue($key, 11); + $this->redis->setTtlValue($key, 60); + + $response->expects($this->once())->method('code')->with(429); + + $result = $this->consumer->checkRateLimitResponse($response, 'id', '/route'); + + $this->assertSame($response, $result); + } + + #[Test] + public function checkRateLimitResponseUsesCustomMaxRetries(): void + { + $response = $this->createMock(Response::class); + $key = md5('id' . '/route'); + $this->redis->setStoreValue($key, 4); + $this->redis->setTtlValue($key, 60); + + $response->expects($this->once())->method('code')->with(429); + + $result = $this->consumer->checkRateLimitResponse($response, 'id', '/route', 3); + + $this->assertSame($response, $result); + } + + // ─── incrementRateLimitCounter tests ───────────────────────────── + + #[Test] + public function incrementRateLimitCounterSetsKeyWhenNotExists(): void + { + $this->consumer->incrementRateLimitCounter('user@test.com', '/api/route'); + + $key = md5('user@test.com' . '/api/route'); + $store = $this->redis->getStore(); + $this->assertEquals(1, $store[$key]); + } + + #[Test] + public function incrementRateLimitCounterIncrementsWhenKeyExists(): void + { + $key = md5('user@test.com' . '/api/route'); + $this->redis->setStoreValue($key, 5); + + $this->consumer->incrementRateLimitCounter('user@test.com', '/api/route'); + + $store = $this->redis->getStore(); + $this->assertEquals(6, $store[$key]); + } + + #[Test] + public function incrementRateLimitCounterSetsExpireOnNewKey(): void + { + $this->consumer->incrementRateLimitCounter('id', '/route'); + + $expireCalls = array_filter($this->redis->calls, fn($c) => $c['method'] === 'expire'); + $this->assertNotEmpty($expireCalls); + $lastExpire = end($expireCalls); + $this->assertGreaterThan(60, $lastExpire['args'][1]); + $this->assertLessThanOrEqual(120, $lastExpire['args'][1]); + } + + #[Test] + public function incrementRateLimitCounterDoesNotSetExpireOnExistingKey(): void + { + $key = md5('id' . '/route'); + $this->redis->setStoreValue($key, 3); + + $this->consumer->incrementRateLimitCounter('id', '/route'); + + // incr is called, but not expire + $expireCalls = array_filter($this->redis->calls, fn($c) => $c['method'] === 'expire'); + $this->assertEmpty($expireCalls); + } + + // ─── getKey tests ──────────────────────────────────────────────── + + #[Test] + public function getKeyReturnsMd5HashOfIdentifierAndRoute(): void + { + $identifier = 'user@example.com'; + $route = '/api/v3/segment/disable/42'; + + $expected = md5($identifier . $route); + $result = $this->consumer->publicGetKey($identifier, $route); + + $this->assertEquals($expected, $result); + } + + #[Test] + public function getKeyReturnsDifferentHashesForDifferentIdentifiers(): void + { + $key1 = $this->consumer->publicGetKey('user1@test.com', '/route'); + $key2 = $this->consumer->publicGetKey('user2@test.com', '/route'); + + $this->assertNotEquals($key1, $key2); + } + + #[Test] + public function getKeyReturnsDifferentHashesForDifferentRoutes(): void + { + $key1 = $this->consumer->publicGetKey('user@test.com', '/route/1'); + $key2 = $this->consumer->publicGetKey('user@test.com', '/route/2'); + + $this->assertNotEquals($key1, $key2); + } + + #[Test] + public function getKeyReturnsConsistentHashForSameInput(): void + { + $key1 = $this->consumer->publicGetKey('user@test.com', '/api/route'); + $key2 = $this->consumer->publicGetKey('user@test.com', '/api/route'); + + $this->assertEquals($key1, $key2); + } + + #[Test] + public function getKeyReturns32CharacterString(): void + { + $key = $this->consumer->publicGetKey('any', '/route'); + + $this->assertEquals(32, strlen($key)); + } + + // ─── getTtl tests ──────────────────────────────────────────────── + + #[Test] + public function getTtlReturnsValueBetween61And120(): void + { + $ttl = $this->consumer->publicGetTtl(); + + $this->assertGreaterThanOrEqual(61, $ttl); + $this->assertLessThanOrEqual(120, $ttl); + } + + #[Test] + public function getTtlIsAlwaysGreaterThan60(): void + { + for ($i = 0; $i < 5; $i++) { + $ttl = $this->consumer->publicGetTtl(); + $this->assertGreaterThan(60, $ttl); + } + } + + #[Test] + public function getTtlMatchesExpectedFormula(): void + { + $date = new DateTime(); + $currentSecond = (int)$date->format('s'); + $expectedTtl = 60 + (60 - $currentSecond); + + $ttl = $this->consumer->publicGetTtl(); + + $this->assertEqualsWithDelta($expectedTtl, $ttl, 1); + } + + // ─── Integration-style tests ───────────────────────────────────── + + #[Test] + public function fullFlowIncrementThenCheckStaysUnderLimit(): void + { + $response = $this->createStub(Response::class); + + $this->consumer->incrementRateLimitCounter('user@test.com', '/route'); + + $result = $this->consumer->checkRateLimitResponse($response, 'user@test.com', '/route', 5); + $this->assertNull($result); + } + + #[Test] + public function fullFlowExceedingLimitTriggersRateLimit(): void + { + $response = $this->createMock(Response::class); + $key = md5('user@test.com' . '/route'); + $this->redis->setStoreValue($key, 11); + $this->redis->setTtlValue($key, 50); + + $response->expects($this->once())->method('code')->with(429); + + $result = $this->consumer->checkRateLimitResponse($response, 'user@test.com', '/route', 10); + $this->assertInstanceOf(Response::class, $result); + } + + #[Test] + public function differentIdentifiersHaveIndependentCounters(): void + { + $this->consumer->incrementRateLimitCounter('user1@test.com', '/route'); + $this->consumer->incrementRateLimitCounter('user2@test.com', '/route'); + + $key1 = md5('user1@test.com' . '/route'); + $key2 = md5('user2@test.com' . '/route'); + + $store = $this->redis->getStore(); + $this->assertEquals(1, $store[$key1]); + $this->assertEquals(1, $store[$key2]); + } +} + diff --git a/tests/unit/Traits/SegmentDisabledTraitTest.php b/tests/unit/Traits/SegmentDisabledTraitTest.php new file mode 100644 index 0000000000..50d6d9dfd2 --- /dev/null +++ b/tests/unit/Traits/SegmentDisabledTraitTest.php @@ -0,0 +1,281 @@ +getProperty('cache_con'); + $prop->setValue(null, $client); + } + + public function publicIsSegmentDisabled(int $id_job, int $id_segment): bool + { + return $this->isSegmentDisabled($id_job, $id_segment); + } + + public function publicSaveSegmentDisabledInCache(int $id_job, int $id_segment): void + { + $this->saveSegmentDisabledInCache($id_job, $id_segment); + } + + public function publicDestroySegmentDisabledCache(int $id_job, int $id_segment): void + { + $this->destroySegmentDisabledCache($id_job, $id_segment); + } + + public function getCacheTTL(): int + { + return $this->cacheTTL; + } +} + +#[AllowMockObjectsWithoutExpectations] +class SegmentDisabledTraitTest extends AbstractTest +{ + private SegmentDisabledTraitConsumer $consumer; + private FakeRedisClient $redis; + + protected function setUp(): void + { + parent::setUp(); + $this->redis = new FakeRedisClient(); + $this->consumer = new SegmentDisabledTraitConsumer(); + $this->consumer->setFakeRedis($this->redis); + } + + protected function tearDown(): void + { + parent::tearDown(); + // Reset static cache_con + $ref = new ReflectionClass(SegmentDisabledTraitConsumer::class); + $prop = $ref->getProperty('cache_con'); + $prop->setValue(null, null); + } + + // ─── isSegmentDisabled tests ───────────────────────────────────── + + #[Test] + public function isSegmentDisabledReturnsTrueWhenCachedValueIsOne(): void + { + $keyMap = 'segment_is_disabled_1_42'; + $query = '__SEGMENT_IS_DISABLED__1_42'; + $hashKey = md5($query); + + $this->redis->setHashValue($keyMap, $hashKey, serialize([1])); + + $result = $this->consumer->publicIsSegmentDisabled(1, 42); + + $this->assertTrue($result); + } + + #[Test] + public function isSegmentDisabledReturnsFalseWhenCachedValueIsZero(): void + { + $keyMap = 'segment_is_disabled_1_42'; + $query = '__SEGMENT_IS_DISABLED__1_42'; + $hashKey = md5($query); + + $this->redis->setHashValue($keyMap, $hashKey, serialize([0])); + + $result = $this->consumer->publicIsSegmentDisabled(1, 42); + + $this->assertFalse($result); + } + + #[Test] + public function isSegmentDisabledReturnsTrueForDifferentIds(): void + { + $keyMap = 'segment_is_disabled_999_888'; + $query = '__SEGMENT_IS_DISABLED__999_888'; + $hashKey = md5($query); + + $this->redis->setHashValue($keyMap, $hashKey, serialize([1])); + + $this->assertTrue($this->consumer->publicIsSegmentDisabled(999, 888)); + } + + #[Test] + public function isSegmentDisabledUsesCorrectCacheKey(): void + { + $idJob = 7; + $idSegment = 99; + $expectedKeyMap = 'segment_is_disabled_7_99'; + $expectedQuery = '__SEGMENT_IS_DISABLED__7_99'; + $expectedHashKey = md5($expectedQuery); + + $this->redis->setHashValue($expectedKeyMap, $expectedHashKey, serialize([1])); + + $result = $this->consumer->publicIsSegmentDisabled($idJob, $idSegment); + + $this->assertTrue($result); + + // Verify hget was called with the correct key + $hgetCalls = array_filter($this->redis->calls, fn($c) => $c['method'] === 'hget'); + $firstHget = reset($hgetCalls); + $this->assertEquals($expectedKeyMap, $firstHget['args'][0]); + $this->assertEquals($expectedHashKey, $firstHget['args'][1]); + } + + // ─── saveSegmentDisabledInCache tests ──────────────────────────── + + #[Test] + public function saveSegmentDisabledInCacheSetsValueInRedis(): void + { + $this->consumer->publicSaveSegmentDisabledInCache(3, 50); + + $expectedKeyMap = 'segment_is_disabled_3_50'; + $expectedQuery = '__SEGMENT_IS_DISABLED__3_50'; + $expectedHashKey = md5($expectedQuery); + + // Verify hset was called + $hsetCalls = array_filter($this->redis->calls, fn($c) => $c['method'] === 'hset'); + $this->assertNotEmpty($hsetCalls); + $firstHset = reset($hsetCalls); + $this->assertEquals($expectedKeyMap, $firstHset['args'][0]); + $this->assertEquals($expectedHashKey, $firstHset['args'][1]); + $this->assertEquals(serialize([1]), $firstHset['args'][2]); + } + + #[Test] + public function saveSegmentDisabledInCacheSetsTtl(): void + { + $this->consumer->publicSaveSegmentDisabledInCache(1, 1); + + $expireCalls = array_filter($this->redis->calls, fn($c) => $c['method'] === 'expire'); + $this->assertNotEmpty($expireCalls); + $firstExpire = reset($expireCalls); + $this->assertEquals(3600, $firstExpire['args'][1]); + } + + #[Test] + public function saveSegmentDisabledInCacheWithDifferentIds(): void + { + $this->consumer->publicSaveSegmentDisabledInCache(123, 456); + + $expectedKeyMap = 'segment_is_disabled_123_456'; + + $hsetCalls = array_filter($this->redis->calls, fn($c) => $c['method'] === 'hset'); + $firstHset = reset($hsetCalls); + $this->assertEquals($expectedKeyMap, $firstHset['args'][0]); + } + + // ─── destroySegmentDisabledCache tests ─────────────────────────── + + #[Test] + public function destroySegmentDisabledCacheDeletesCacheKey(): void + { + // destroySegmentDisabledCache calls SegmentMetadataDao::delete (static, hits DB) + // We verify only that the cache key format is correct and del would be called + $this->markTestSkipped('Requires database connection for SegmentMetadataDao::delete'); + } + + // ─── Cache key consistency tests ───────────────────────────────── + + #[Test] + public function cacheKeyFormatIsConsistent(): void + { + $keyMap = 'segment_is_disabled_10_20'; + $query = '__SEGMENT_IS_DISABLED__10_20'; + $hashKey = md5($query); + + $this->redis->setHashValue($keyMap, $hashKey, serialize([1])); + + // Call twice + $this->consumer->publicIsSegmentDisabled(10, 20); + $this->consumer->publicIsSegmentDisabled(10, 20); + + $hgetCalls = array_filter($this->redis->calls, fn($c) => $c['method'] === 'hget'); + $keys = array_map(fn($c) => $c['args'][0], $hgetCalls); + + $this->assertCount(2, $keys); + $this->assertEquals($keys[0], $keys[1]); + $this->assertEquals('segment_is_disabled_10_20', $keys[0]); + } + + #[Test] + public function differentJobAndSegmentIdsProduceDifferentCacheKeys(): void + { + $keyMap1 = 'segment_is_disabled_1_100'; + $query1 = '__SEGMENT_IS_DISABLED__1_100'; + $this->redis->setHashValue($keyMap1, md5($query1), serialize([0])); + + $keyMap2 = 'segment_is_disabled_2_200'; + $query2 = '__SEGMENT_IS_DISABLED__2_200'; + $this->redis->setHashValue($keyMap2, md5($query2), serialize([0])); + + $this->consumer->publicIsSegmentDisabled(1, 100); + $this->consumer->publicIsSegmentDisabled(2, 200); + + $hgetCalls = array_filter($this->redis->calls, fn($c) => $c['method'] === 'hget'); + $keys = array_map(fn($c) => $c['args'][0], array_values($hgetCalls)); + + $this->assertNotEquals($keys[0], $keys[1]); + } + + // ─── CACHE_TTL constant test ───────────────────────────────────── + + #[Test] + public function cacheTtlConstantIs3600(): void + { + $ref = new ReflectionClass(SegmentDisabledTraitConsumer::class); + $this->assertEquals(3600, $ref->getConstant('CACHE_TTL')); + } + + // ─── Edge cases ────────────────────────────────────────────────── + + #[Test] + public function isSegmentDisabledWithZeroIds(): void + { + $keyMap = 'segment_is_disabled_0_0'; + $query = '__SEGMENT_IS_DISABLED__0_0'; + $this->redis->setHashValue($keyMap, md5($query), serialize([1])); + + $this->assertTrue($this->consumer->publicIsSegmentDisabled(0, 0)); + } + + #[Test] + public function saveAndCheckConsistency(): void + { + $this->consumer->publicSaveSegmentDisabledInCache(42, 77); + + // After saving, the hash should contain the value + $keyMap = 'segment_is_disabled_42_77'; + $query = '__SEGMENT_IS_DISABLED__42_77'; + $hashKey = md5($query); + + $store = $this->redis->getHashStore(); + $this->assertEquals(serialize([1]), $store[$keyMap][$hashKey] ?? null); + } + + #[Test] + public function cacheInitSetsTtlCorrectly(): void + { + $keyMap = 'segment_is_disabled_1_1'; + $query = '__SEGMENT_IS_DISABLED__1_1'; + $this->redis->setHashValue($keyMap, md5($query), serialize([0])); + + $this->consumer->publicIsSegmentDisabled(1, 1); + + $this->assertEquals(3600, $this->consumer->getCacheTTL()); + } +} +