mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-10-16 02:09:44 +00:00
Compare commits
16 Commits
425-rename
...
v4.6.0
Author | SHA1 | Date | |
---|---|---|---|
8ad6f397a8
|
|||
d713704633 | |||
b1fa9242a0 | |||
6ac554f93a | |||
372d8e5825 | |||
10f05e5559 | |||
ddb2a65419 | |||
8d40a8089f | |||
e1bf4a24d2 | |||
208a378185 | |||
9089c8959b | |||
1b9b581c31
|
|||
aa1abe4c88 | |||
d82c9cc9a7 | |||
a7e3b1c5d2 | |||
84cf11933d |
@@ -1,6 +0,0 @@
|
|||||||
kind: Fixed
|
|
||||||
body: Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted
|
|
||||||
time: 2025-10-03T22:40:44.685474863+02:00
|
|
||||||
custom:
|
|
||||||
Issue: ""
|
|
||||||
SchemaChange: No schema change
|
|
@@ -1,6 +0,0 @@
|
|||||||
kind: Fixed
|
|
||||||
body: 'Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists'
|
|
||||||
time: 2025-10-06T12:13:15.45905994+02:00
|
|
||||||
custom:
|
|
||||||
Issue: "434"
|
|
||||||
SchemaChange: No schema change
|
|
14
.changes/v4.6.0.md
Normal file
14
.changes/v4.6.0.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
## v4.6.0 - 2025-10-15
|
||||||
|
### Feature
|
||||||
|
* ([#423](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/423)) Create environment banner that can be activated and configured depending on the image deployed
|
||||||
|
* ([#394](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/394)) Only show active workflow on the page "my tracked workflow"
|
||||||
|
### Fixed
|
||||||
|
* Fix loading of classLists in SocialIssuesAcc.vue, ensure elements are present
|
||||||
|
* Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted
|
||||||
|
* ([#434](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/434)) Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists
|
||||||
|
* Fix loading of social issues and social actions within vue component
|
||||||
|
* ([#446](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/446)) Add unique condition on stored object filename, with cleaning step on existing duplicate filenames
|
||||||
|
|
||||||
|
**Schema Change**: Drop or rename table or columns, or enforce new constraint that must be manually fixed
|
||||||
|
* [workflow] take permissions into account to delete the workflow attachment
|
||||||
|
* ([#448](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/448)) Fix the execution of daily cronjob notification, when the previous last execution storage was invalid
|
@@ -240,9 +240,6 @@ The tests are run from the project's root (not from the bundle's root).
|
|||||||
# Run all tests
|
# Run all tests
|
||||||
vendor/bin/phpunit
|
vendor/bin/phpunit
|
||||||
|
|
||||||
# Run tests for a specific bundle
|
|
||||||
vendor/bin/phpunit --testsuite NameBundle
|
|
||||||
|
|
||||||
# Run a specific test file
|
# Run a specific test file
|
||||||
vendor/bin/phpunit path/to/TestFile.php
|
vendor/bin/phpunit path/to/TestFile.php
|
||||||
|
|
||||||
@@ -250,6 +247,9 @@ vendor/bin/phpunit path/to/TestFile.php
|
|||||||
vendor/bin/phpunit --filter methodName path/to/TestFile.php
|
vendor/bin/phpunit --filter methodName path/to/TestFile.php
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When writing tests, only test specific files. Do not run all tests or the full
|
||||||
|
test suite.
|
||||||
|
|
||||||
#### Test Structure
|
#### Test Structure
|
||||||
|
|
||||||
Tests are organized by bundle and follow the same structure as the bundle itself:
|
Tests are organized by bundle and follow the same structure as the bundle itself:
|
||||||
|
15
CHANGELOG.md
15
CHANGELOG.md
@@ -6,6 +6,21 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
|
|||||||
and is generated by [Changie](https://github.com/miniscruff/changie).
|
and is generated by [Changie](https://github.com/miniscruff/changie).
|
||||||
|
|
||||||
|
|
||||||
|
## v4.6.0 - 2025-10-15
|
||||||
|
### Feature
|
||||||
|
* ([#423](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/423)) Create environment banner that can be activated and configured depending on the image deployed
|
||||||
|
* ([#394](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/394)) Only show active workflow on the page "my tracked workflow"
|
||||||
|
### Fixed
|
||||||
|
* Fix loading of classLists in SocialIssuesAcc.vue, ensure elements are present
|
||||||
|
* Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted
|
||||||
|
* ([#434](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/434)) Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists
|
||||||
|
* Fix loading of social issues and social actions within vue component
|
||||||
|
* ([#446](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/446)) Add unique condition on stored object filename, with cleaning step on existing duplicate filenames
|
||||||
|
|
||||||
|
**Schema Change**: Drop or rename table or columns, or enforce new constraint that must be manually fixed
|
||||||
|
* [workflow] take permissions into account to delete the workflow attachment
|
||||||
|
* ([#448](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/448)) Fix the execution of daily cronjob notification, when the previous last execution storage was invalid
|
||||||
|
|
||||||
## v4.5.1 - 2025-10-03
|
## v4.5.1 - 2025-10-03
|
||||||
### Fixed
|
### Fixed
|
||||||
* Add missing javascript dependency
|
* Add missing javascript dependency
|
||||||
|
@@ -1,6 +1,13 @@
|
|||||||
chill_main:
|
chill_main:
|
||||||
available_languages: [ '%env(resolve:LOCALE)%', 'en' ]
|
available_languages: [ '%env(resolve:LOCALE)%', 'en' ]
|
||||||
available_countries: ['BE', 'FR']
|
available_countries: ['BE', 'FR']
|
||||||
|
top_banner:
|
||||||
|
visible: false
|
||||||
|
text:
|
||||||
|
fr: 'Vous travaillez actuellement avec la version de PRÉ-PRODUCTION.'
|
||||||
|
nl: 'Je werkt momenteel in de PRE-PRODUCTIE versie'
|
||||||
|
color: '#353535'
|
||||||
|
background_color: '#d8bb48'
|
||||||
notifications:
|
notifications:
|
||||||
from_email: '%env(resolve:NOTIFICATION_FROM_EMAIL)%'
|
from_email: '%env(resolve:NOTIFICATION_FROM_EMAIL)%'
|
||||||
from_name: '%env(resolve:NOTIFICATION_FROM_NAME)%'
|
from_name: '%env(resolve:NOTIFICATION_FROM_NAME)%'
|
||||||
|
@@ -136,8 +136,14 @@ export default {
|
|||||||
issueIsLoading: false,
|
issueIsLoading: false,
|
||||||
actionIsLoading: false,
|
actionIsLoading: false,
|
||||||
actionAreLoaded: false,
|
actionAreLoaded: false,
|
||||||
socialIssuesClassList: `col-form-label ${document.querySelector("input#chill_activitybundle_activity_socialIssues").getAttribute("required") ? "required" : ""}`,
|
socialIssuesClassList: {
|
||||||
socialActionsClassList: `col-form-label ${document.querySelector("input#chill_activitybundle_activity_socialActions").getAttribute("required") ? "required" : ""}`,
|
"col-form-label": true,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
socialActionsClassList: {
|
||||||
|
"col-form-label": true,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -158,6 +164,21 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
/* Load classNames after element is present */
|
||||||
|
const socialActionsEl = document.querySelector(
|
||||||
|
"input#chill_activitybundle_activity_socialActions",
|
||||||
|
);
|
||||||
|
if (socialActionsEl && socialActionsEl.hasAttribute("required")) {
|
||||||
|
this.socialActionsClassList.required = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socialIssuesEl = document.querySelector(
|
||||||
|
"input#chill_activitybundle_activity_socialIssues",
|
||||||
|
);
|
||||||
|
if (socialIssuesEl && socialIssuesEl.hasAttribute("required")) {
|
||||||
|
this.socialIssuesClassList.required = true;
|
||||||
|
}
|
||||||
|
|
||||||
/* Load other issues in multiselect */
|
/* Load other issues in multiselect */
|
||||||
this.issueIsLoading = true;
|
this.issueIsLoading = true;
|
||||||
this.actionAreLoaded = false;
|
this.actionAreLoaded = false;
|
||||||
|
@@ -23,10 +23,14 @@ use Random\RandomException;
|
|||||||
* Store each version of StoredObject's.
|
* Store each version of StoredObject's.
|
||||||
*
|
*
|
||||||
* A version should not be created manually: use the method @see{StoredObject::registerVersion} instead.
|
* A version should not be created manually: use the method @see{StoredObject::registerVersion} instead.
|
||||||
|
*
|
||||||
|
* Each filename must be unique within the same StoredObject. We add a condition on id to apply this condition only for
|
||||||
|
* newly created versions when this new index is applied.
|
||||||
*/
|
*/
|
||||||
#[ORM\Entity]
|
#[ORM\Entity]
|
||||||
#[ORM\Table('chill_doc.stored_object_version')]
|
#[ORM\Table('chill_doc.stored_object_version')]
|
||||||
#[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_object', columns: ['stored_object_id', 'version'])]
|
#[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_object', columns: ['stored_object_id', 'version'])]
|
||||||
|
#[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_filename', columns: ['filename'], options: ['where' => '(id > 0)'])]
|
||||||
class StoredObjectVersion implements TrackCreationInterface
|
class StoredObjectVersion implements TrackCreationInterface
|
||||||
{
|
{
|
||||||
use TrackCreationTrait;
|
use TrackCreationTrait;
|
||||||
|
@@ -36,6 +36,18 @@ export interface GenericDocForAccompanyingPeriod extends GenericDoc {
|
|||||||
context: "accompanying-period";
|
context: "accompanying-period";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isGenericDocForAccompanyingPeriod(
|
||||||
|
doc: GenericDoc,
|
||||||
|
): doc is GenericDocForAccompanyingPeriod {
|
||||||
|
return doc.context === "accompanying-period";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGenericDocWithStoredObject(
|
||||||
|
doc: GenericDoc,
|
||||||
|
): doc is GenericDoc & { storedObject: StoredObject } {
|
||||||
|
return doc.storedObject !== null;
|
||||||
|
}
|
||||||
|
|
||||||
interface BaseMetadataWithHtml extends BaseMetadata {
|
interface BaseMetadataWithHtml extends BaseMetadata {
|
||||||
html: string;
|
html: string;
|
||||||
}
|
}
|
||||||
@@ -44,28 +56,33 @@ export interface GenericDocForAccompanyingCourseDocument
|
|||||||
extends GenericDocForAccompanyingPeriod {
|
extends GenericDocForAccompanyingPeriod {
|
||||||
key: "accompanying_course_document";
|
key: "accompanying_course_document";
|
||||||
metadata: BaseMetadataWithHtml;
|
metadata: BaseMetadataWithHtml;
|
||||||
|
storedObject: StoredObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenericDocForAccompanyingCourseActivityDocument
|
export interface GenericDocForAccompanyingCourseActivityDocument
|
||||||
extends GenericDocForAccompanyingPeriod {
|
extends GenericDocForAccompanyingPeriod {
|
||||||
key: "accompanying_course_activity_document";
|
key: "accompanying_course_activity_document";
|
||||||
metadata: BaseMetadataWithHtml;
|
metadata: BaseMetadataWithHtml;
|
||||||
|
storedObject: StoredObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenericDocForAccompanyingCourseCalendarDocument
|
export interface GenericDocForAccompanyingCourseCalendarDocument
|
||||||
extends GenericDocForAccompanyingPeriod {
|
extends GenericDocForAccompanyingPeriod {
|
||||||
key: "accompanying_course_calendar_document";
|
key: "accompanying_course_calendar_document";
|
||||||
metadata: BaseMetadataWithHtml;
|
metadata: BaseMetadataWithHtml;
|
||||||
|
storedObject: StoredObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenericDocForAccompanyingCoursePersonDocument
|
export interface GenericDocForAccompanyingCoursePersonDocument
|
||||||
extends GenericDocForAccompanyingPeriod {
|
extends GenericDocForAccompanyingPeriod {
|
||||||
key: "person_document";
|
key: "person_document";
|
||||||
metadata: BaseMetadataWithHtml;
|
metadata: BaseMetadataWithHtml;
|
||||||
|
storedObject: StoredObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenericDocForAccompanyingCourseWorkEvaluationDocument
|
export interface GenericDocForAccompanyingCourseWorkEvaluationDocument
|
||||||
extends GenericDocForAccompanyingPeriod {
|
extends GenericDocForAccompanyingPeriod {
|
||||||
key: "accompanying_period_work_evaluation_document";
|
key: "accompanying_period_work_evaluation_document";
|
||||||
metadata: BaseMetadataWithHtml;
|
metadata: BaseMetadataWithHtml;
|
||||||
|
storedObject: StoredObject;
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\Migrations\DocStore;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20251013094414 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'DocStore: Enforce filename uniqueness on chill_doc.stored_object_version; clean duplicates and add partial unique index on filename (for new rows only).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// 1) Clean duplicates: for each (stored_object_id, filename, key, iv), keep only the last inserted row
|
||||||
|
// and delete all others. Use ROW_NUMBER over id DESC to define the last one.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT id,
|
||||||
|
rank() OVER (
|
||||||
|
PARTITION BY stored_object_id, filename, "key"::jsonb, iv::jsonb
|
||||||
|
ORDER BY id DESC
|
||||||
|
) AS rn
|
||||||
|
FROM chill_doc.stored_object_version
|
||||||
|
)
|
||||||
|
DELETE FROM chill_doc.stored_object_version sov
|
||||||
|
USING ranked r
|
||||||
|
WHERE sov.id = r.id
|
||||||
|
AND r.rn > 1
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// 2) Create a partial unique index on filename that applies only to subsequently inserted rows.
|
||||||
|
// Per user's instruction, compute the cutoff using the stored_object_id sequence value.
|
||||||
|
$nextVal = (int) $this->connection->fetchOne("SELECT nextval('chill_doc.stored_object_version_id_seq')");
|
||||||
|
|
||||||
|
// Safety: if somehow sequence is not available, fallback to current max id from the table
|
||||||
|
if ($nextVal <= 0) {
|
||||||
|
$nextVal = (int) $this->connection->fetchOne('SELECT COALESCE(MAX(id), 0) FROM chill_doc.stored_object_version');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql(sprintf(
|
||||||
|
'CREATE UNIQUE INDEX chill_doc_stored_object_version_unique_by_filename ON chill_doc.stored_object_version (filename) WHERE id > %d',
|
||||||
|
$nextVal
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Drop the partial unique index; data cleanup is irreversible.
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS chill_doc_stored_object_version_unique_by_filename');
|
||||||
|
}
|
||||||
|
}
|
@@ -264,11 +264,12 @@ class WorkflowController extends AbstractController
|
|||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||||
|
|
||||||
$total = $this->entityWorkflowRepository->countBySubscriber($this->security->getUser());
|
$total = $this->entityWorkflowRepository->countBySubscriber($this->security->getUser(), false);
|
||||||
$paginator = $this->paginatorFactory->create($total);
|
$paginator = $this->paginatorFactory->create($total);
|
||||||
|
|
||||||
$workflows = $this->entityWorkflowRepository->findBySubscriber(
|
$workflows = $this->entityWorkflowRepository->findBySubscriber(
|
||||||
$this->security->getUser(),
|
$this->security->getUser(),
|
||||||
|
false,
|
||||||
['createdAt' => 'DESC'],
|
['createdAt' => 'DESC'],
|
||||||
$paginator->getItemsPerPage(),
|
$paginator->getItemsPerPage(),
|
||||||
$paginator->getCurrentPageFirstItemNumber()
|
$paginator->getCurrentPageFirstItemNumber()
|
||||||
|
@@ -205,6 +205,11 @@ class ChillMainExtension extends Extension implements
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$container->setParameter(
|
||||||
|
'chill_main.top_banner',
|
||||||
|
$config['top_banner'] ?? []
|
||||||
|
);
|
||||||
|
|
||||||
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
|
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
|
||||||
$loader->load('services.yaml');
|
$loader->load('services.yaml');
|
||||||
$loader->load('services/doctrine.yaml');
|
$loader->load('services/doctrine.yaml');
|
||||||
@@ -250,6 +255,7 @@ class ChillMainExtension extends Extension implements
|
|||||||
'name' => $config['installation_name'], ],
|
'name' => $config['installation_name'], ],
|
||||||
'available_languages' => $config['available_languages'],
|
'available_languages' => $config['available_languages'],
|
||||||
'add_address' => $config['add_address'],
|
'add_address' => $config['add_address'],
|
||||||
|
'chill_main_config' => $config,
|
||||||
],
|
],
|
||||||
'form_themes' => ['@ChillMain/Form/fields.html.twig'],
|
'form_themes' => ['@ChillMain/Form/fields.html.twig'],
|
||||||
];
|
];
|
||||||
|
@@ -168,6 +168,20 @@ class Configuration implements ConfigurationInterface
|
|||||||
->end()
|
->end()
|
||||||
->end()
|
->end()
|
||||||
->end()
|
->end()
|
||||||
|
->arrayNode('top_banner')
|
||||||
|
->canBeUnset()
|
||||||
|
->children()
|
||||||
|
->booleanNode('visible')
|
||||||
|
->defaultFalse()
|
||||||
|
->end()
|
||||||
|
->arrayNode('text')
|
||||||
|
->useAttributeAsKey('lang')
|
||||||
|
->scalarPrototype()->end()
|
||||||
|
->end() // end of text
|
||||||
|
->scalarNode('color')->defaultNull()->end()
|
||||||
|
->scalarNode('background_color')->defaultNull()->end()
|
||||||
|
->end() // end of top_banner children
|
||||||
|
->end() // end of top_banner
|
||||||
->arrayNode('widgets')
|
->arrayNode('widgets')
|
||||||
->canBeEnabled()
|
->canBeEnabled()
|
||||||
->canBeUnset()
|
->canBeUnset()
|
||||||
|
@@ -53,11 +53,16 @@ readonly class DailyNotificationDigestCronjob implements CronJobInterface
|
|||||||
public function run(array $lastExecutionData): ?array
|
public function run(array $lastExecutionData): ?array
|
||||||
{
|
{
|
||||||
$now = $this->clock->now();
|
$now = $this->clock->now();
|
||||||
|
|
||||||
if (isset($lastExecutionData['last_execution'])) {
|
if (isset($lastExecutionData['last_execution'])) {
|
||||||
$lastExecution = \DateTimeImmutable::createFromFormat(
|
$lastExecution = \DateTimeImmutable::createFromFormat(
|
||||||
\DateTimeImmutable::ATOM,
|
\DateTimeImmutable::ATOM,
|
||||||
$lastExecutionData['last_execution']
|
$lastExecutionData['last_execution']
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (false === $lastExecution) {
|
||||||
|
$lastExecution = $now->sub(new \DateInterval('P1D'));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$lastExecution = $now->sub(new \DateInterval('P1D'));
|
$lastExecution = $now->sub(new \DateInterval('P1D'));
|
||||||
}
|
}
|
||||||
@@ -96,7 +101,7 @@ readonly class DailyNotificationDigestCronjob implements CronJobInterface
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'last_execution' => $now->format('Y-m-d-H:i:s.u e'),
|
'last_execution' => $now->format(\DateTimeInterface::ATOM),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -57,9 +57,15 @@ class EntityWorkflowRepository implements ObjectRepository
|
|||||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function countBySubscriber(User $user): int
|
/**
|
||||||
|
* @param bool|null $isFinal true to get only the entityWorkflow which is finalized, false to get the workflows that are not finalized, and null to ignore
|
||||||
|
*
|
||||||
|
* @throws \Doctrine\ORM\NoResultException
|
||||||
|
* @throws \Doctrine\ORM\NonUniqueResultException
|
||||||
|
*/
|
||||||
|
public function countBySubscriber(User $user, ?bool $isFinal = null): int
|
||||||
{
|
{
|
||||||
$qb = $this->buildQueryBySubscriber($user)->select('count(ew)');
|
$qb = $this->buildQueryBySubscriber($user, $isFinal)->select('count(ew)');
|
||||||
|
|
||||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||||
}
|
}
|
||||||
@@ -182,9 +188,14 @@ class EntityWorkflowRepository implements ObjectRepository
|
|||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findBySubscriber(User $user, ?array $orderBy = null, $limit = null, $offset = null): array
|
/**
|
||||||
|
* @param bool|null $isFinal true to get only the entityWorkflow which is finalized, false to get the workflows that are not finalized, and null to ignore
|
||||||
|
* @param mixed|null $limit
|
||||||
|
* @param mixed|null $offset
|
||||||
|
*/
|
||||||
|
public function findBySubscriber(User $user, ?bool $isFinal = null, ?array $orderBy = null, $limit = null, $offset = null): array
|
||||||
{
|
{
|
||||||
$qb = $this->buildQueryBySubscriber($user)->select('ew');
|
$qb = $this->buildQueryBySubscriber($user, $isFinal)->select('ew');
|
||||||
|
|
||||||
foreach ($orderBy as $key => $sort) {
|
foreach ($orderBy as $key => $sort) {
|
||||||
$qb->addOrderBy('ew.'.$key, $sort);
|
$qb->addOrderBy('ew.'.$key, $sort);
|
||||||
@@ -312,7 +323,7 @@ class EntityWorkflowRepository implements ObjectRepository
|
|||||||
return $qb;
|
return $qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildQueryBySubscriber(User $user): QueryBuilder
|
private function buildQueryBySubscriber(User $user, ?bool $isFinal): QueryBuilder
|
||||||
{
|
{
|
||||||
$qb = $this->repository->createQueryBuilder('ew');
|
$qb = $this->repository->createQueryBuilder('ew');
|
||||||
|
|
||||||
@@ -325,6 +336,14 @@ class EntityWorkflowRepository implements ObjectRepository
|
|||||||
|
|
||||||
$qb->setParameter('user', $user);
|
$qb->setParameter('user', $user);
|
||||||
|
|
||||||
|
if (null !== $isFinal) {
|
||||||
|
if ($isFinal) {
|
||||||
|
$qb->andWhere(sprintf('EXISTS (SELECT 1 FROM %s step WHERE step.isFinal = true AND ew = step.entityWorkflow)', EntityWorkflowStep::class));
|
||||||
|
} else {
|
||||||
|
$qb->andWhere(sprintf('NOT EXISTS (SELECT 1 FROM %s step WHERE step.isFinal = true AND ew = step.entityWorkflow)', EntityWorkflowStep::class));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $qb;
|
return $qb;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,7 @@
|
|||||||
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
|
import {
|
||||||
|
GenericDoc,
|
||||||
|
isGenericDocWithStoredObject,
|
||||||
|
} from "ChillDocStoreAssets/types/generic_doc";
|
||||||
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
|
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
|
||||||
import { Person } from "../../../ChillPersonBundle/Resources/public/types";
|
import { Person } from "../../../ChillPersonBundle/Resources/public/types";
|
||||||
|
|
||||||
@@ -203,6 +206,25 @@ export interface WorkflowAttachment {
|
|||||||
genericDoc: null | GenericDoc;
|
genericDoc: null | GenericDoc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AttachmentWithDocAndStored = WorkflowAttachment & {
|
||||||
|
genericDoc: GenericDoc & { storedObject: StoredObject };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isAttachmentWithDocAndStored(
|
||||||
|
a: WorkflowAttachment,
|
||||||
|
): a is AttachmentWithDocAndStored {
|
||||||
|
return (
|
||||||
|
isWorkflowAttachmentWithGenericDoc(a) &&
|
||||||
|
isGenericDocWithStoredObject(a.genericDoc)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkflowAttachmentWithGenericDoc(
|
||||||
|
attachment: WorkflowAttachment,
|
||||||
|
): attachment is WorkflowAttachment & { genericDoc: GenericDoc } {
|
||||||
|
return attachment.genericDoc !== null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Workflow {
|
export interface Workflow {
|
||||||
name: string;
|
name: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
@@ -6,6 +6,7 @@ import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/gener
|
|||||||
import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue";
|
import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue";
|
||||||
import { GenericDoc } from "ChillDocStoreAssets/types";
|
import { GenericDoc } from "ChillDocStoreAssets/types";
|
||||||
import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api";
|
import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api";
|
||||||
|
import { trans, WORKFLOW_ATTACHMENTS_ADD_AN_ATTACHMENT } from "translator";
|
||||||
|
|
||||||
interface AppConfig {
|
interface AppConfig {
|
||||||
workflowId: number;
|
workflowId: number;
|
||||||
@@ -83,7 +84,7 @@ const canEditAttachement = computed<boolean>(() => {
|
|||||||
<ul v-if="canEditAttachement" class="record_actions">
|
<ul v-if="canEditAttachement" class="record_actions">
|
||||||
<li>
|
<li>
|
||||||
<button type="button" class="btn btn-create" @click="openModal">
|
<button type="button" class="btn btn-create" @click="openModal">
|
||||||
Ajouter une pièce jointe
|
{{ trans(WORKFLOW_ATTACHMENTS_ADD_AN_ATTACHMENT) }}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@@ -1,7 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types";
|
import {
|
||||||
|
AttachmentWithDocAndStored,
|
||||||
|
EntityWorkflow,
|
||||||
|
isAttachmentWithDocAndStored,
|
||||||
|
WorkflowAttachment,
|
||||||
|
} from "ChillMainAssets/types";
|
||||||
import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue";
|
import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue";
|
||||||
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { trans, WORKFLOW_ATTACHMENTS_NO_ATTACHMENT } from "translator";
|
||||||
|
|
||||||
interface AttachmentListProps {
|
interface AttachmentListProps {
|
||||||
attachments: WorkflowAttachment[];
|
attachments: WorkflowAttachment[];
|
||||||
@@ -14,35 +21,43 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const props = defineProps<AttachmentListProps>();
|
const props = defineProps<AttachmentListProps>();
|
||||||
|
|
||||||
|
const notNullAttachments = computed<AttachmentWithDocAndStored[]>(() =>
|
||||||
|
props.attachments.filter(
|
||||||
|
(a: WorkflowAttachment): a is AttachmentWithDocAndStored =>
|
||||||
|
isAttachmentWithDocAndStored(a),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const canRemove = computed<boolean>((): boolean => {
|
||||||
|
if (null === props.workflow) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.workflow._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<p
|
<p
|
||||||
v-if="props.attachments.length === 0"
|
v-if="notNullAttachments.length === 0"
|
||||||
class="chill-no-data-statement text-center"
|
class="chill-no-data-statement text-center"
|
||||||
>
|
>
|
||||||
Aucune pièce jointe
|
{{ trans(WORKFLOW_ATTACHMENTS_NO_ATTACHMENT) }}
|
||||||
</p>
|
</p>
|
||||||
<!-- TODO translate -->
|
<div v-else class="flex-table">
|
||||||
<div else class="flex-table">
|
<div v-for="a in notNullAttachments" :key="a.id" class="item-bloc">
|
||||||
<div v-for="a in props.attachments" :key="a.id" class="item-bloc">
|
|
||||||
<generic-doc-item-box
|
<generic-doc-item-box
|
||||||
v-if="a.genericDoc !== null"
|
|
||||||
:generic-doc="a.genericDoc"
|
:generic-doc="a.genericDoc"
|
||||||
></generic-doc-item-box>
|
></generic-doc-item-box>
|
||||||
<div class="item-row separator">
|
<div class="item-row separator">
|
||||||
<ul class="record_actions">
|
<ul class="record_actions">
|
||||||
<li v-if="a.genericDoc?.storedObject !== null">
|
<li>
|
||||||
<document-action-buttons-group
|
<document-action-buttons-group
|
||||||
:stored-object="a.genericDoc.storedObject"
|
:stored-object="a.genericDoc.storedObject"
|
||||||
></document-action-buttons-group>
|
></document-action-buttons-group>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li v-if="canRemove">
|
||||||
v-if="
|
|
||||||
!workflow?._permissions
|
|
||||||
.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-delete"
|
class="btn btn-delete"
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
|
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
|
||||||
|
|
||||||
interface GenericDocItemBoxProps {
|
interface GenericDocItemBoxProps {
|
||||||
genericDoc: GenericDocForAccompanyingPeriod;
|
genericDoc: GenericDoc;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<GenericDocItemBoxProps>();
|
const props = defineProps<GenericDocItemBoxProps>();
|
||||||
|
@@ -0,0 +1,17 @@
|
|||||||
|
{% if chill_main_config.top_banner is defined and chill_main_config.top_banner.text is defined %}
|
||||||
|
{% set banner_text = '' %}
|
||||||
|
{% set current_locale = app.request.locale %}
|
||||||
|
|
||||||
|
{% if chill_main_config.top_banner.text[current_locale] is defined %}
|
||||||
|
{% set banner_text = chill_main_config.top_banner.text[current_locale] %}
|
||||||
|
{% else %}
|
||||||
|
{% set banner_text = chill_main_config.top_banner.text|first %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if banner_text %}
|
||||||
|
<div class="top-banner w-100 text-center py-2"
|
||||||
|
style="{% if chill_main_config.top_banner.color is defined %}color: {{ chill_main_config.top_banner.color }};{% endif %}{% if chill_main_config.top_banner.background_color is defined %}background-color: {{ chill_main_config.top_banner.background_color }};{% endif %}">
|
||||||
|
{{ banner_text }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
@@ -26,6 +26,10 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
{% if chill_main_config.top_banner is defined and chill_main_config.top_banner.visible is true %}
|
||||||
|
{{ include('@ChillMain/Layout/_top_banner.html.twig') }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if responsive_debug is defined and responsive_debug == 1 %}
|
{% if responsive_debug is defined and responsive_debug == 1 %}
|
||||||
{{ include('@ChillMain/Layout/_debug.html.twig') }}
|
{{ include('@ChillMain/Layout/_debug.html.twig') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\MainBundle\Tests\DependencyInjection;
|
||||||
|
|
||||||
|
use Chill\MainBundle\DependencyInjection\Configuration;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Config\Definition\Processor;
|
||||||
|
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class ConfigurationTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testTopBannerConfiguration(): void
|
||||||
|
{
|
||||||
|
$containerBuilder = new ContainerBuilder();
|
||||||
|
$configuration = new Configuration([], $containerBuilder);
|
||||||
|
$processor = new Processor();
|
||||||
|
|
||||||
|
// Test with top_banner configuration
|
||||||
|
$config = [
|
||||||
|
'chill_main' => [
|
||||||
|
'top_banner' => [
|
||||||
|
'text' => [
|
||||||
|
'fr' => 'Vous travaillez actuellement avec la version de pré-production de Chill.',
|
||||||
|
'nl' => 'Je werkte momenteel in de pré-productie versie van Chill.',
|
||||||
|
],
|
||||||
|
'color' => 'white',
|
||||||
|
'background-color' => 'red',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$processedConfig = $processor->processConfiguration($configuration, $config);
|
||||||
|
|
||||||
|
self::assertArrayHasKey('top_banner', $processedConfig);
|
||||||
|
self::assertArrayHasKey('text', $processedConfig['top_banner']);
|
||||||
|
self::assertArrayHasKey('fr', $processedConfig['top_banner']['text']);
|
||||||
|
self::assertArrayHasKey('nl', $processedConfig['top_banner']['text']);
|
||||||
|
self::assertSame('white', $processedConfig['top_banner']['color']);
|
||||||
|
self::assertSame('red', $processedConfig['top_banner']['background_color']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTopBannerConfigurationOptional(): void
|
||||||
|
{
|
||||||
|
$containerBuilder = new ContainerBuilder();
|
||||||
|
$configuration = new Configuration([], $containerBuilder);
|
||||||
|
$processor = new Processor();
|
||||||
|
|
||||||
|
// Test without top_banner configuration
|
||||||
|
$config = [
|
||||||
|
'chill_main' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$processedConfig = $processor->processConfiguration($configuration, $config);
|
||||||
|
|
||||||
|
// top_banner should not be present when not configured
|
||||||
|
self::assertArrayNotHasKey('top_banner', $processedConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTopBannerWithMinimalConfiguration(): void
|
||||||
|
{
|
||||||
|
$containerBuilder = new ContainerBuilder();
|
||||||
|
$configuration = new Configuration([], $containerBuilder);
|
||||||
|
$processor = new Processor();
|
||||||
|
|
||||||
|
// Test with minimal top_banner configuration (only text)
|
||||||
|
$config = [
|
||||||
|
'chill_main' => [
|
||||||
|
'top_banner' => [
|
||||||
|
'text' => [
|
||||||
|
'fr' => 'Test message',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$processedConfig = $processor->processConfiguration($configuration, $config);
|
||||||
|
|
||||||
|
self::assertArrayHasKey('top_banner', $processedConfig);
|
||||||
|
self::assertArrayHasKey('text', $processedConfig['top_banner']);
|
||||||
|
self::assertSame('Test message', $processedConfig['top_banner']['text']['fr']);
|
||||||
|
self::assertNull($processedConfig['top_banner']['color']);
|
||||||
|
self::assertNull($processedConfig['top_banner']['background_color']);
|
||||||
|
}
|
||||||
|
}
|
@@ -37,10 +37,5 @@ class DailyNotificationDigestCronJobFunctionalTest extends KernelTestCase
|
|||||||
$actual = $this->dailyNotificationDigestCronjob->run([]);
|
$actual = $this->dailyNotificationDigestCronjob->run([]);
|
||||||
|
|
||||||
self::assertArrayHasKey('last_execution', $actual);
|
self::assertArrayHasKey('last_execution', $actual);
|
||||||
self::assertInstanceOf(
|
|
||||||
\DateTimeImmutable::class,
|
|
||||||
\DateTimeImmutable::createFromFormat('Y-m-d-H:i:s.u e', $actual['last_execution']),
|
|
||||||
'test that the string can be converted to a date'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -12,16 +12,21 @@ declare(strict_types=1);
|
|||||||
namespace Chill\MainBundle\Tests\Notification\Email;
|
namespace Chill\MainBundle\Tests\Notification\Email;
|
||||||
|
|
||||||
use Chill\MainBundle\Notification\Email\DailyNotificationDigestCronjob;
|
use Chill\MainBundle\Notification\Email\DailyNotificationDigestCronjob;
|
||||||
|
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage;
|
||||||
use Doctrine\DBAL\Connection;
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Doctrine\DBAL\Result;
|
||||||
|
use Doctrine\DBAL\Statement;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\Clock\ClockInterface;
|
use Symfony\Component\Clock\ClockInterface;
|
||||||
|
use Symfony\Component\Clock\MockClock;
|
||||||
|
use Symfony\Component\Messenger\Envelope;
|
||||||
use Symfony\Component\Messenger\MessageBusInterface;
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*
|
*
|
||||||
* @coversNothing
|
* @covers \DailyNotificationDigestCronjob
|
||||||
*/
|
*/
|
||||||
class DailyNotificationDigestCronJobTest extends TestCase
|
class DailyNotificationDigestCronJobTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -30,6 +35,7 @@ class DailyNotificationDigestCronJobTest extends TestCase
|
|||||||
private MessageBusInterface $messageBus;
|
private MessageBusInterface $messageBus;
|
||||||
private LoggerInterface $logger;
|
private LoggerInterface $logger;
|
||||||
private DailyNotificationDigestCronjob $cronjob;
|
private DailyNotificationDigestCronjob $cronjob;
|
||||||
|
private \DateTimeImmutable $firstNow;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
@@ -38,6 +44,8 @@ class DailyNotificationDigestCronJobTest extends TestCase
|
|||||||
$this->messageBus = $this->createMock(MessageBusInterface::class);
|
$this->messageBus = $this->createMock(MessageBusInterface::class);
|
||||||
$this->logger = $this->createMock(LoggerInterface::class);
|
$this->logger = $this->createMock(LoggerInterface::class);
|
||||||
|
|
||||||
|
$this->firstNow = new \DateTimeImmutable('2024-01-02T07:15:00+00:00');
|
||||||
|
|
||||||
$this->cronjob = new DailyNotificationDigestCronjob(
|
$this->cronjob = new DailyNotificationDigestCronjob(
|
||||||
$this->clock,
|
$this->clock,
|
||||||
$this->connection,
|
$this->connection,
|
||||||
@@ -78,4 +86,129 @@ class DailyNotificationDigestCronJobTest extends TestCase
|
|||||||
'hour 23 - should not run' => [23, false],
|
'hour 23 - should not run' => [23, false],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testRunFirstExecutionReturnsStateAndDispatches(): array
|
||||||
|
{
|
||||||
|
// Use MockClock for deterministic time
|
||||||
|
$firstNow = $this->firstNow;
|
||||||
|
$clock = new MockClock($firstNow);
|
||||||
|
|
||||||
|
// Mock DBAL statement/result
|
||||||
|
$statement = $this->createMock(Statement::class);
|
||||||
|
$result = $this->createMock(Result::class);
|
||||||
|
|
||||||
|
$this->connection->method('prepare')->willReturn($statement);
|
||||||
|
$statement->method('bindValue')->willReturnSelf();
|
||||||
|
$statement->method('executeQuery')->willReturn($result);
|
||||||
|
|
||||||
|
$rows = [
|
||||||
|
['user_id' => 10],
|
||||||
|
['user_id' => 42],
|
||||||
|
];
|
||||||
|
$result->method('fetchAllAssociative')->willReturn($rows);
|
||||||
|
|
||||||
|
$dispatched = [];
|
||||||
|
$this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$dispatched) {
|
||||||
|
$dispatched[] = $message;
|
||||||
|
|
||||||
|
return new Envelope($message);
|
||||||
|
});
|
||||||
|
|
||||||
|
$cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger);
|
||||||
|
$state = $cron->run([]);
|
||||||
|
|
||||||
|
// Assert dispatch count and message contents
|
||||||
|
self::assertCount(2, $dispatched);
|
||||||
|
$expectedLast = $firstNow->sub(new \DateInterval('P1D'));
|
||||||
|
foreach ($dispatched as $i => $msg) {
|
||||||
|
self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg);
|
||||||
|
self::assertTrue(in_array($msg->getUserId(), [10, 42], true));
|
||||||
|
self::assertEquals($firstNow, $msg->getCurrentDateTime(), 'compare the current date');
|
||||||
|
self::assertEquals($expectedLast, $msg->getLastExecutionDateTime(), 'compare the last execution date');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert returned state
|
||||||
|
self::assertIsArray($state);
|
||||||
|
self::assertArrayHasKey('last_execution', $state);
|
||||||
|
self::assertSame($firstNow->format(\DateTimeInterface::ATOM), $state['last_execution']);
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @depends testRunFirstExecutionReturnsStateAndDispatches
|
||||||
|
*/
|
||||||
|
public function testRunSecondExecutionUsesPreviousState(array $previousState): void
|
||||||
|
{
|
||||||
|
$firstNow = $this->firstNow;
|
||||||
|
$secondNow = $firstNow->add(new \DateInterval('P1D'));
|
||||||
|
$clock = new MockClock($secondNow);
|
||||||
|
|
||||||
|
// Mock DBAL for a single user this time
|
||||||
|
$statement = $this->createMock(Statement::class);
|
||||||
|
$result = $this->createMock(Result::class);
|
||||||
|
|
||||||
|
$this->connection->method('prepare')->willReturn($statement);
|
||||||
|
$statement->method('bindValue')->willReturnSelf();
|
||||||
|
$statement->method('executeQuery')->willReturn($result);
|
||||||
|
|
||||||
|
$rows = [
|
||||||
|
['user_id' => 7],
|
||||||
|
];
|
||||||
|
$result->method('fetchAllAssociative')->willReturn($rows);
|
||||||
|
|
||||||
|
$captured = [];
|
||||||
|
$this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$captured) {
|
||||||
|
$captured[] = $message;
|
||||||
|
|
||||||
|
return new Envelope($message);
|
||||||
|
});
|
||||||
|
|
||||||
|
$cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger);
|
||||||
|
$cron->run($previousState);
|
||||||
|
|
||||||
|
self::assertCount(1, $captured);
|
||||||
|
$msg = $captured[0];
|
||||||
|
self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg);
|
||||||
|
self::assertEquals(7, $msg->getUserId());
|
||||||
|
self::assertEquals($secondNow, $msg->getCurrentDateTime(), 'compare the current date');
|
||||||
|
self::assertEquals($firstNow, $msg->getLastExecutionDateTime(), 'compare the last execution date');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRunWithInvalidExecutionState(): void
|
||||||
|
{
|
||||||
|
$firstNow = new \DateTimeImmutable('2025-10-14T10:30:00 Europe/Brussels');
|
||||||
|
$previousExpected = $firstNow->sub(new \DateInterval('P1D'));
|
||||||
|
$clock = new MockClock($firstNow);
|
||||||
|
|
||||||
|
// Mock DBAL for a single user this time
|
||||||
|
$statement = $this->createMock(Statement::class);
|
||||||
|
$result = $this->createMock(Result::class);
|
||||||
|
|
||||||
|
$this->connection->method('prepare')->willReturn($statement);
|
||||||
|
$statement->method('bindValue')->willReturnSelf();
|
||||||
|
$statement->method('executeQuery')->willReturn($result);
|
||||||
|
|
||||||
|
$rows = [
|
||||||
|
['user_id' => 7],
|
||||||
|
];
|
||||||
|
$result->method('fetchAllAssociative')->willReturn($rows);
|
||||||
|
|
||||||
|
$captured = [];
|
||||||
|
$this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$captured) {
|
||||||
|
$captured[] = $message;
|
||||||
|
|
||||||
|
return new Envelope($message);
|
||||||
|
});
|
||||||
|
|
||||||
|
$cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger);
|
||||||
|
$cron->run(['last_execution' => 'invalid data']);
|
||||||
|
|
||||||
|
self::assertCount(1, $captured);
|
||||||
|
$msg = $captured[0];
|
||||||
|
self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg);
|
||||||
|
self::assertEquals(7, $msg->getUserId());
|
||||||
|
self::assertEquals($firstNow, $msg->getCurrentDateTime(), 'compare the current date');
|
||||||
|
self::assertEquals($previousExpected, $msg->getLastExecutionDateTime(), 'compare the last execution date');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -670,6 +670,8 @@ workflow:
|
|||||||
|
|
||||||
attachments:
|
attachments:
|
||||||
title: Pièces jointes
|
title: Pièces jointes
|
||||||
|
no_attachment: Aucune pièce jointe
|
||||||
|
Add_an_attachment: Ajouter une pièce jointe
|
||||||
|
|
||||||
wait:
|
wait:
|
||||||
title: En attente de traitement
|
title: En attente de traitement
|
||||||
|
Reference in New Issue
Block a user