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());
+ }
+}
+