diff --git a/UPGRADING.md b/UPGRADING.md index 5c5b9455..e20cf570 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -22,6 +22,10 @@ Perform EACH version specific task between your version and the new one, otherwi - **BC break**: interface method signature `HtmlToPdfConverter::convertToPdf` changed - **BC break**: the macros `badge` and `label` do not apply the `|trans` filter any more - **BC Break**: removed `getVisible()` (deprecated since 1.4) method on Customer, Project and Activity (use `isVisible()` instead, templates are still working) +- **BC Break**: API changes + - some representation names changed (eg. from `ActivityMetaField` to `ActivityMeta`, `TimesheetSubCollection` vs `TimesheetCollectionExpanded`), you could use `class_alias()` if you use auto-generated code from Swagger-Gen or alike + - new result types were introduced + - result data changed in some areas to smooth out inconsistencies (eg. TeamEntity fields changed in nested results) ## [1.9](https://github.com/kevinpapst/kimai2/releases/tag/1.9) diff --git a/config/packages/jms_serializer.yaml b/config/packages/jms_serializer.yaml index 8a81ed87..c3780453 100644 --- a/config/packages/jms_serializer.yaml +++ b/config/packages/jms_serializer.yaml @@ -14,9 +14,6 @@ jms_serializer: FOSUB: namespace_prefix: "FOS\\UserBundle" path: "%kernel.root_dir%/../config/serializer/FOS/UserBundle" - App: - namespace_prefix: "App" - path: "%kernel.root_dir%/../config/serializer/App" warmup: paths: included: diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml index b71a7371..c3fec75c 100644 --- a/config/packages/nelmio_api_doc.yaml +++ b/config/packages/nelmio_api_doc.yaml @@ -2,41 +2,40 @@ nelmio_api_doc: models: use_jms: true names: - - { alias: CustomerEditForm, type: App\Form\API\CustomerApiEditForm, groups: [Default, Entity, Customer] } - - { alias: CustomerEntity, type: App\Entity\Customer, groups: [Default, Entity, Customer] } - - { alias: CustomerRate, type: App\Entity\CustomerRate, groups: [Default, Entity, CustomerRate] } - - { alias: CustomerRateForm, type: App\Form\API\CustomerRateApiForm, groups: [Default, Entity, CustomerRate] } - - { alias: CustomerMetaField, type: App\Entity\CustomerMeta, groups: [Default, Customer] } - - { alias: CustomerCollection, type: App\Entity\Customer, groups: [Default, Collection, Customer] } - - { alias: ProjectEditForm, type: App\Form\API\ProjectApiEditForm, groups: [Default, Entity, Project] } - - { alias: ProjectEntity, type: App\Entity\Project, groups: [Default, Entity, Project] } - - { alias: ProjectRate, type: App\Entity\ProjectRate, groups: [Default, Entity, ProjectRate] } - - { alias: ProjectRateForm, type: App\Form\API\ProjectRateApiForm, groups: [Default, Entity, ProjectRate] } - - { alias: ProjectMetaField, type: App\Entity\ProjectMeta, groups: [Default, Project] } - - { alias: ProjectCollection, type: App\Entity\Project, groups: [Default, Collection, Project] } - - { alias: ActivityEditForm, type: App\Form\API\ActivityApiEditForm, groups: [Default, Entity, Activity] } - - { alias: ActivityEntity, type: App\Entity\Activity, groups: [Default, Entity, Activity] } - - { alias: ActivityRate, type: App\Entity\ActivityRate, groups: [Default, Entity, ActivityRate] } - - { alias: ActivityRateForm, type: App\Form\API\ActivityRateApiForm, groups: [Default, Entity, ActivityRate] } - - { alias: ActivityMetaField, type: App\Entity\ActivityMeta, groups: [Default, Activity] } - - { alias: ActivityCollection, type: App\Entity\Activity, groups: [Default, Collection, Activity] } - - { alias: TagEditForm, type: App\Form\API\TagApiEditForm, groups: [Default, Entity, Tag] } - - { alias: TagEntity, type: App\Entity\Tag, groups: [Default, Entity, Tag] } - - { alias: TimesheetEditForm, type: App\Form\API\TimesheetApiEditForm, groups: [Default, Entity, Timesheet] } - - { alias: TimesheetEntity, type: App\Entity\Timesheet, groups: [Default, Entity, Timesheet] } - - { alias: TimesheetMeta, type: App\Entity\TimesheetMeta, groups: [Default, Timesheet] } - - { alias: TimesheetCollection, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet] } - - { alias: TimesheetSubCollection, type: App\Entity\Timesheet, groups: [Default, Subresource, Timesheet] } - - { alias: UserCreateForm, type: App\Form\API\UserApiCreateForm, groups: [Default, Entity, User, User_Entity] } - - { alias: UserEditForm, type: App\Form\API\UserApiEditForm, groups: [Default, Entity, User, User_Entity] } - - { alias: User, type: App\Entity\User, groups: [Default, Entity, User] } - - { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User, User_Entity] } - - { alias: UserCollection, type: App\Entity\User, groups: [Default, Collection, User] } - - { alias: TeamEditForm, type: App\Form\API\TeamApiEditForm, groups: [Default, Entity, Team] } - - { alias: TeamEntity, type: App\Entity\Team, groups: [Default, Entity, Team, Team_Entity] } - - { alias: TeamCollection, type: App\Entity\Team, groups: [Default, Collection, Team] } - - { alias: I18nConfig, type: App\API\Model\I18n, groups: [Default] } - - { alias: TimesheetConfig, type: App\API\Model\TimesheetConfig, groups: [Default] } + - { alias: CustomerEditForm, type: App\Form\API\CustomerApiEditForm, groups: [Default, Entity, Customer] } + - { alias: CustomerEntity, type: App\Entity\Customer, groups: [Default, Entity, Customer, Customer_Entity] } + - { alias: Customer, type: App\Entity\Customer, groups: [Default] } + - { alias: CustomerRate, type: App\Entity\CustomerRate, groups: [Default, Entity, Customer_Rate] } + - { alias: CustomerRateForm, type: App\Form\API\CustomerRateApiForm, groups: [Default, Entity, Customer_Rate] } + - { alias: CustomerCollection, type: App\Entity\Customer, groups: [Default, Collection, Customer] } + - { alias: ProjectEditForm, type: App\Form\API\ProjectApiEditForm, groups: [Default, Entity, Project] } + - { alias: ProjectEntity, type: App\Entity\Project, groups: [Default, Entity, Project, Project_Entity] } + - { alias: Project, type: App\Entity\Project, groups: [Default] } + - { alias: ProjectExpanded, type: App\Entity\Project, groups: [Default, Expanded] } + - { alias: ProjectRate, type: App\Entity\ProjectRate, groups: [Default, Entity, Project_Rate] } + - { alias: ProjectRateForm, type: App\Form\API\ProjectRateApiForm, groups: [Default, Entity, Project_Rate] } + - { alias: ProjectCollection, type: App\Entity\Project, groups: [Default, Collection, Project] } + - { alias: ActivityEditForm, type: App\Form\API\ActivityApiEditForm, groups: [Default, Entity, Activity] } + - { alias: ActivityEntity, type: App\Entity\Activity, groups: [Default, Entity, Activity, Activity_Entity] } + - { alias: Activity, type: App\Entity\Activity, groups: [Default] } + - { alias: ActivityExpanded, type: App\Entity\Activity, groups: [Default, Expanded] } + - { alias: ActivityRate, type: App\Entity\ActivityRate, groups: [Default, Entity, Activity_Rate] } + - { alias: ActivityRateForm, type: App\Form\API\ActivityRateApiForm, groups: [Default, Entity, Activity_Rate] } + - { alias: ActivityCollection, type: App\Entity\Activity, groups: [Default, Collection, Activity] } + - { alias: TagEditForm, type: App\Form\API\TagApiEditForm, groups: [Default, Entity, Tag] } + - { alias: TagEntity, type: App\Entity\Tag, groups: [Default, Entity, Tag] } + - { alias: TimesheetEditForm, type: App\Form\API\TimesheetApiEditForm, groups: [Default, Entity, Timesheet, Not_Expanded] } + - { alias: TimesheetEntity, type: App\Entity\Timesheet, groups: [Default, Entity, Timesheet, Timesheet_Entity, Not_Expanded] } + - { alias: TimesheetCollection, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet, Not_Expanded] } + - { alias: TimesheetCollectionExpanded, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet, Subresource, Expanded] } + - { alias: UserCreateForm, type: App\Form\API\UserApiCreateForm, groups: [Default, Entity, User, User_Entity] } + - { alias: UserEditForm, type: App\Form\API\UserApiEditForm, groups: [Default, Entity, User, User_Entity] } + - { alias: User, type: App\Entity\User, groups: [Default] } + - { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User, User_Entity] } + - { alias: UserCollection, type: App\Entity\User, groups: [Default, Collection, User] } + - { alias: TeamEditForm, type: App\Form\API\TeamApiEditForm, groups: [Default, Entity, Team, Team_Entity] } + - { alias: TeamEntity, type: App\Entity\Team, groups: [Default, Entity, Team, Team_Entity] } + - { alias: TeamCollection, type: App\Entity\Team, groups: [Default, Collection, Team] } areas: path_patterns: - ^/api(?!/doc) @@ -49,8 +48,9 @@ nelmio_api_doc: title: Kimai 2 - API Docs description: | JSON API for the Kimai 2 time-tracking software. Read more about its usage in the [API documentation](https://www.kimai.org/documentation/rest-api.html) and then download a [Swagger file](doc.json) for import e.g. in Postman. - Be aware: it is not yet considered stable and BC breaks might happen. - version: '0.5' + Be aware: it is not yet considered stable and BC breaks might happen, especially when using code generation. + The order of JSON attributes is not guaranteed. + version: '0.6' securityDefinitions: apiUser: type: apiKey diff --git a/config/serializer/App/API.Model.I18n.yml b/config/serializer/App/API.Model.I18n.yml deleted file mode 100644 index a3920c83..00000000 --- a/config/serializer/App/API.Model.I18n.yml +++ /dev/null @@ -1,32 +0,0 @@ -App\API\Model\I18n: - exclusion_policy: All - custom_accessor_order: [formDateTime, formDate, dateTime, date, time, duration, is24hours] - properties: - formDateTime: - include: true - type: string - groups: [Default] - formDate: - include: true - type: string - groups: [Default] - dateTime: - include: true - type: string - groups: [Default] - time: - include: true - type: string - groups: [Default] - date: - include: true - type: string - groups: [Default] - duration: - include: true - type: string - groups: [Default] - is24hours: - include: true - type: boolean - groups: [Default] diff --git a/config/serializer/App/API.Model.Version.yml b/config/serializer/App/API.Model.Version.yml deleted file mode 100644 index 393389a8..00000000 --- a/config/serializer/App/API.Model.Version.yml +++ /dev/null @@ -1,25 +0,0 @@ -App\API\Model\Version: - exclusion_policy: All - custom_accessor_order: [version, candidate, semver, name, copyright] - properties: - version: - include: true - type: string - example: 0.9 - groups: [Default] - candidate: - include: true - type: string - groups: [Default] - semver: - include: true - type: string - groups: [Default] - name: - include: true - type: string - groups: [Default] - copyright: - include: true - type: string - groups: [Default] diff --git a/config/serializer/App/Entity.Activity.yml b/config/serializer/App/Entity.Activity.yml deleted file mode 100644 index a9a37378..00000000 --- a/config/serializer/App/Entity.Activity.yml +++ /dev/null @@ -1,46 +0,0 @@ -App\Entity\Activity: - exclusion_policy: All - custom_accessor_order: [id, name, comment, visible, project, color, budget, timeBudget, metaFields, parentTitle] - properties: - id: - include: true - groups: [Default] - name: - include: true - groups: [Default] - comment: - include: true - groups: [Entity] - visible: - include: true - groups: [Default] - budget: - include: true - groups: [Entity] - timeBudget: - include: true - groups: [Entity] - project: - include: false - exclude: true - groups: [Default] - color: - include: true - metaFields: - exclude: true - virtual_properties: - parentTitle: - serialized_name: parentTitle - include: true - exp: "object.getProject() === null ? null : object.getProject().getName()" - groups: [Default] - getProject: - serialized_name: project - exp: "object.getProject() === null ? null : object.getProject().getId()" - type: integer - groups: [Default] - getMetaFields: - serialized_name: metaFields - exp: "object.getVisibleMetaFields()" - type: array - groups: [Default] diff --git a/config/serializer/App/Entity.ActivityMeta.yml b/config/serializer/App/Entity.ActivityMeta.yml deleted file mode 100644 index 517a1aab..00000000 --- a/config/serializer/App/Entity.ActivityMeta.yml +++ /dev/null @@ -1,15 +0,0 @@ -App\Entity\ActivityMeta: - exclusion_policy: All - custom_accessor_order: [name, value] - properties: - name: - include: false - value: - include: false - virtual_properties: - getName: - serialized_name: name - exp: "object.isVisible() ? object.getName() : null" - getValue: - serialized_name: value - exp: "object.isVisible() ? object.getValue() : null" diff --git a/config/serializer/App/Entity.ActivityRate.yml b/config/serializer/App/Entity.ActivityRate.yml deleted file mode 100644 index 0ac75421..00000000 --- a/config/serializer/App/Entity.ActivityRate.yml +++ /dev/null @@ -1,21 +0,0 @@ -App\Entity\ActivityRate: - exclusion_policy: All - custom_accessor_order: [id, rate, internalRate, isFixed, user] - properties: - id: - include: true - groups: [Default] - activity: - exclude: true - user: - include: true - groups: [Default] - rate: - include: true - groups: [Default] - internalRate: - include: true - groups: [Default] - isFixed: - include: true - groups: [Default] diff --git a/config/serializer/App/Entity.Customer.yml b/config/serializer/App/Entity.Customer.yml deleted file mode 100644 index cc6caebb..00000000 --- a/config/serializer/App/Entity.Customer.yml +++ /dev/null @@ -1,68 +0,0 @@ -App\Entity\Customer: - exclusion_policy: All - custom_accessor_order: [id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, email, homepage, timezone, color, budget, timeBudget, metaFields, teams] - properties: - id: - include: true - name: - include: true - number: - include: true - groups: [Entity] - comment: - include: true - groups: [Entity] - visible: - include: true - budget: - include: true - groups: [Entity] - timeBudget: - include: true - groups: [Entity] - company: - include: true - groups: [Entity] - contact: - include: true - groups: [Entity] - address: - include: true - groups: [Entity] - country: - include: true - groups: [Entity] - currency: - include: true - groups: [Entity] - phone: - include: true - groups: [Entity] - fax: - include: true - groups: [Entity] - mobile: - include: true - groups: [Entity] - email: - include: true - groups: [Entity] - homepage: - include: true - groups: [Entity] - timezone: - include: true - groups: [Entity] - color: - include: true - metaFields: - exclude: true - teams: - include: true - groups: [Customer] - virtual_properties: - getMetaFields: - serialized_name: metaFields - exp: "object.getVisibleMetaFields()" - type: array - groups: [Default] diff --git a/config/serializer/App/Entity.CustomerMeta.yml b/config/serializer/App/Entity.CustomerMeta.yml deleted file mode 100644 index c612ea10..00000000 --- a/config/serializer/App/Entity.CustomerMeta.yml +++ /dev/null @@ -1,15 +0,0 @@ -App\Entity\CustomerMeta: - exclusion_policy: All - custom_accessor_order: [name, value] - properties: - name: - include: false - value: - include: false - virtual_properties: - getName: - serialized_name: name - exp: "object.isVisible() ? object.getName() : null" - getValue: - serialized_name: value - exp: "object.isVisible() ? object.getValue() : null" diff --git a/config/serializer/App/Entity.CustomerRate.yml b/config/serializer/App/Entity.CustomerRate.yml deleted file mode 100644 index e6bf8fa6..00000000 --- a/config/serializer/App/Entity.CustomerRate.yml +++ /dev/null @@ -1,21 +0,0 @@ -App\Entity\CustomerRate: - exclusion_policy: All - custom_accessor_order: [id, rate, internalRate, isFixed, user] - properties: - id: - include: true - groups: [Default] - customer: - exclude: true - user: - include: true - groups: [Default] - rate: - include: true - groups: [Default] - internalRate: - include: true - groups: [Default] - isFixed: - include: true - groups: [Default] diff --git a/config/serializer/App/Entity.Project.yml b/config/serializer/App/Entity.Project.yml deleted file mode 100644 index 1f334208..00000000 --- a/config/serializer/App/Entity.Project.yml +++ /dev/null @@ -1,56 +0,0 @@ -App\Entity\Project: - exclusion_policy: All - custom_accessor_order: [id, name, comment, visible, orderNumber, orderDate, customer, start, end, color, budget, timeBudget, metaFields, parentTitle, teams] - properties: - id: - include: true - name: - include: true - comment: - include: true - groups: [Entity] - visible: - include: true - budget: - include: true - groups: [Entity] - timeBudget: - include: true - groups: [Entity] - orderNumber: - include: true - groups: [Entity] - orderDate: - include: true - groups: [Entity] - start: - include: true - groups: [Project] - end: - include: true - groups: [Project] - customer: - groups: [Subresource] - color: - include: true - metaFields: - exclude: true - teams: - include: true - groups: [Project] - virtual_properties: - parentTitle: - serialized_name: parentTitle - include: true - exp: "object.getCustomer() === null ? null : object.getCustomer().getName()" - groups: [Default] - getCustomer: - serialized_name: customer - exp: "object.getCustomer() === null ? null : object.getCustomer().getId()" - type: integer - groups: [Entity, Collection] - getMetaFields: - serialized_name: metaFields - exp: "object.getVisibleMetaFields()" - type: array - groups: [Default] diff --git a/config/serializer/App/Entity.ProjectMeta.yml b/config/serializer/App/Entity.ProjectMeta.yml deleted file mode 100644 index d7d08823..00000000 --- a/config/serializer/App/Entity.ProjectMeta.yml +++ /dev/null @@ -1,15 +0,0 @@ -App\Entity\ProjectMeta: - exclusion_policy: All - custom_accessor_order: [name, value] - properties: - name: - include: false - value: - include: false - virtual_properties: - getName: - serialized_name: name - exp: "object.isVisible() ? object.getName() : null" - getValue: - serialized_name: value - exp: "object.isVisible() ? object.getValue() : null" diff --git a/config/serializer/App/Entity.ProjectRate.yml b/config/serializer/App/Entity.ProjectRate.yml deleted file mode 100644 index 14886799..00000000 --- a/config/serializer/App/Entity.ProjectRate.yml +++ /dev/null @@ -1,21 +0,0 @@ -App\Entity\ProjectRate: - exclusion_policy: All - custom_accessor_order: [id, rate, internalRate, isFixed, user] - properties: - id: - include: true - groups: [Default] - project: - exclude: true - user: - include: true - groups: [Default] - rate: - include: true - groups: [Default] - internalRate: - include: true - groups: [Default] - isFixed: - include: true - groups: [Default] diff --git a/config/serializer/App/Entity.Tag.yml b/config/serializer/App/Entity.Tag.yml deleted file mode 100644 index 3686116f..00000000 --- a/config/serializer/App/Entity.Tag.yml +++ /dev/null @@ -1,12 +0,0 @@ -App\Entity\Tag: - exclusion_policy: All - custom_accessor_order: [id, name, color] - properties: - id: - include: true - name: - include: true - color: - include: true - timesheets: - exclude: true diff --git a/config/serializer/App/Entity.Team.yml b/config/serializer/App/Entity.Team.yml deleted file mode 100644 index 57bbf650..00000000 --- a/config/serializer/App/Entity.Team.yml +++ /dev/null @@ -1,20 +0,0 @@ -App\Entity\Team: - exclusion_policy: All - custom_accessor_order: [id, name, teamlead, users, customers, projects] - properties: - id: - include: true - name: - include: true - teamlead: - include: true - groups: [Team_Entity] - users: - include: true - groups: [Team_Entity] - customers: - include: true - groups: [Team_Entity] - projects: - include: true - groups: [Team_Entity] diff --git a/config/serializer/App/Entity.Timesheet.yml b/config/serializer/App/Entity.Timesheet.yml deleted file mode 100644 index b86e35cf..00000000 --- a/config/serializer/App/Entity.Timesheet.yml +++ /dev/null @@ -1,67 +0,0 @@ -App\Entity\Timesheet: - exclusion_policy: All - custom_accessor_order: [id, begin, end, duration, rate, activity, project, user, description, fixedRate, hourlyRate, tags, exported, metaFields] - properties: - id: - include: true - begin: - exclude: true - end: - exclude: true - duration: - include: true - description: - include: true - rate: - include: true - internalRate: - include: true - fixedRate: - include: true - groups: [Entity] - hourlyRate: - include: true - groups: [Entity] - exported: - include: true - groups: [Entity] - activity: - groups: [Subresource] - project: - groups: [Subresource] - user: - exclude: true - metaFields: - exclude: true - virtual_properties: - getBegin: - serialized_name: begin - exp: "object.getBegin() === null ? null : object.getBegin()" - type: DateTime - getEnd: - serialized_name: end - exp: "object.getEnd() === null ? null : object.getEnd()" - type: DateTime - getActivity: - serialized_name: activity - exp: "object.getActivity() === null ? null : object.getActivity().getId()" - type: integer - groups: [Entity, Collection] - getProject: - serialized_name: project - exp: "object.getProject() === null ? null : object.getProject().getId()" - type: integer - groups: [Entity, Collection] - getUser: - serialized_name: user - exp: "object.getUser().getId()" - type: integer - getTags: - serialized_name: tags - exp: "object.getTagsAsArray()" - type: array - getMetaFields: - serialized_name: metaFields - exp: "object.getVisibleMetaFields()" - type: array - groups: [Default] diff --git a/config/serializer/App/Entity.TimesheetMeta.yml b/config/serializer/App/Entity.TimesheetMeta.yml deleted file mode 100644 index 2908f238..00000000 --- a/config/serializer/App/Entity.TimesheetMeta.yml +++ /dev/null @@ -1,15 +0,0 @@ -App\Entity\TimesheetMeta: - exclusion_policy: All - custom_accessor_order: [name, value] - properties: - name: - include: false - value: - include: false - virtual_properties: - getName: - serialized_name: name - exp: "object.isVisible() ? object.getName() : null" - getValue: - serialized_name: value - exp: "object.isVisible() ? object.getValue() : null" diff --git a/config/serializer/App/Entity.User.yml b/config/serializer/App/Entity.User.yml deleted file mode 100644 index 1bfdae5a..00000000 --- a/config/serializer/App/Entity.User.yml +++ /dev/null @@ -1,30 +0,0 @@ -App\Entity\User: - exclusion_policy: All - custom_accessor_order: [id, alias, title, avatar, language, timezone, teams] - properties: - id: - include: true - groups: [Default] - alias: - include: true - groups: [Default] - title: - include: true - groups: [User_Entity] - avatar: - include: true - groups: [User_Entity] - teams: - include: true - groups: [User_Entity] - virtual_properties: - getLanguage: - serialized_name: language - exp: "object.getPreferenceValue('language') === null ? null : object.getPreferenceValue('language')" - type: string - groups: [User_Entity] - getTimezone: - serialized_name: timezone - exp: "object.getPreferenceValue('timezone') === null ? null : object.getPreferenceValue('timezone')" - type: string - groups: [User_Entity] diff --git a/config/validator/validation.yaml b/config/validator/validation.yaml index 496e246a..5efa3e11 100644 --- a/config/validator/validation.yaml +++ b/config/validator/validation.yaml @@ -1,16 +1,16 @@ App\Entity\User: properties: roles: - - App\Validator\Constraints\Role: ~ + - App\Validator\Constraints\Role: { groups: [RolesUpdate] } username: - - NotBlank: ~ - - Length: { min: 2, max: 60 } + - NotBlank: { groups: [Registration, UserCreate, Profile] } + - Length: { min: 2, max: 60, groups: [Registration, UserCreate, Profile] } email: - - NotBlank: ~ - - Email: ~ + - NotBlank: { groups: [Registration, UserCreate, Profile] } + - Email: { groups: [Registration, UserCreate, Profile] } plainPassword: - - NotBlank: { groups: [Registration, PasswordUpdate] } - - Length: { min: 8, max: 60, groups: [Registration, PasswordUpdate] } + - NotBlank: { groups: [Registration, PasswordUpdate, UserCreate] } + - Length: { min: 8, max: 60, groups: [Registration, PasswordUpdate, UserCreate] } plainApiToken: - NotBlank: { groups: [ApiTokenUpdate] } - Length: { min: 8, max: 60, groups: [ApiTokenUpdate] } diff --git a/src/API/ActivityController.php b/src/API/ActivityController.php index 7a108330..15c8f358 100644 --- a/src/API/ActivityController.php +++ b/src/API/ActivityController.php @@ -41,6 +41,11 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; */ class ActivityController extends BaseApiController { + public const GROUPS_ENTITY = ['Default', 'Entity', 'Activity', 'Activity_Entity']; + public const GROUPS_FORM = ['Default', 'Entity', 'Activity']; + public const GROUPS_COLLECTION = ['Default', 'Collection', 'Activity']; + public const GROUPS_RATE = ['Default', 'Entity', 'Activity_Rate']; + /** * @var ActivityRepository */ @@ -132,7 +137,7 @@ class ActivityController extends BaseApiController $data = $this->repository->getActivitiesForQuery($query); $view = new View($data, 200); - $view->getContext()->setGroups(['Default', 'Collection', 'Activity']); + $view->getContext()->setGroups(self::GROUPS_COLLECTION); return $this->viewHandler->handle($view); } @@ -165,7 +170,7 @@ class ActivityController extends BaseApiController } $view = new View($data, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Activity']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -212,13 +217,13 @@ class ActivityController extends BaseApiController $this->repository->saveActivity($activity); $view = new View($activity, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Activity']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } $view = new View($form); - $view->getContext()->setGroups(['Default', 'Entity', 'Activity']); + $view->getContext()->setGroups(self::GROUPS_FORM); return $this->viewHandler->handle($view); } @@ -275,7 +280,7 @@ class ActivityController extends BaseApiController if (false === $form->isValid()) { $view = new View($form, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'Activity']); + $view->getContext()->setGroups(self::GROUPS_FORM); return $this->viewHandler->handle($view); } @@ -283,7 +288,7 @@ class ActivityController extends BaseApiController $this->repository->saveActivity($activity); $view = new View($activity, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'Activity']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -336,7 +341,7 @@ class ActivityController extends BaseApiController $this->repository->saveActivity($activity); $view = new View($activity, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Activity']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -379,7 +384,7 @@ class ActivityController extends BaseApiController $rates = $this->activityRateRepository->getRatesForActivity($activity); $view = new View($rates, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'ActivityRate']); + $view->getContext()->setGroups(self::GROUPS_RATE); return $this->viewHandler->handle($view); } @@ -490,7 +495,7 @@ class ActivityController extends BaseApiController if (false === $form->isValid()) { $view = new View($form, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'ActivityRate']); + $view->getContext()->setGroups(self::GROUPS_RATE); return $this->viewHandler->handle($view); } @@ -498,7 +503,7 @@ class ActivityController extends BaseApiController $this->activityRateRepository->saveRate($rate); $view = new View($rate, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'ActivityRate']); + $view->getContext()->setGroups(self::GROUPS_RATE); return $this->viewHandler->handle($view); } diff --git a/src/API/ConfigurationController.php b/src/API/ConfigurationController.php index 4fd40dd0..5235e133 100644 --- a/src/API/ConfigurationController.php +++ b/src/API/ConfigurationController.php @@ -11,7 +11,7 @@ declare(strict_types=1); namespace App\API; -use App\API\Model\I18n; +use App\API\Model\I18nConfig; use App\API\Model\TimesheetConfig; use App\Configuration\LanguageFormattings; use App\Configuration\TimesheetConfiguration; @@ -19,6 +19,7 @@ use App\Entity\User; use FOS\RestBundle\Controller\Annotations as Rest; use FOS\RestBundle\View\View; use FOS\RestBundle\View\ViewHandlerInterface; +use Nelmio\ApiDocBundle\Annotation\Model; use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Swagger\Annotations as SWG; @@ -44,11 +45,6 @@ final class ConfigurationController extends BaseApiController */ private $timesheetConfiguration; - /** - * @param ViewHandlerInterface $viewHandler - * @param LanguageFormattings $formats - * @param TimesheetConfiguration $timesheetConfiguration - */ public function __construct(ViewHandlerInterface $viewHandler, LanguageFormattings $formats, TimesheetConfiguration $timesheetConfiguration) { $this->viewHandler = $viewHandler; @@ -62,7 +58,7 @@ final class ConfigurationController extends BaseApiController * @SWG\Response( * response=200, * description="Returns the locale specific configurations for this user", - * @SWG\Schema(ref="#/definitions/I18nConfig") + * @SWG\Schema(ref=@Model(type=I18nConfig::class)) * ) * * @Rest\Get(path="/config/i18n") @@ -76,7 +72,7 @@ final class ConfigurationController extends BaseApiController $user = $this->getUser(); $locale = $user->getLocale(); - $model = new I18n(); + $model = new I18nConfig(); $model ->setFormDateTime($this->formats->getDateTimeTypeFormat($locale)) ->setFormDate($this->formats->getDateTypeFormat($locale)) @@ -99,7 +95,7 @@ final class ConfigurationController extends BaseApiController * @SWG\Response( * response=200, * description="Returns the instance specific timesheet configuration", - * @SWG\Schema(ref="#/definitions/TimesheetConfig") + * @SWG\Schema(ref=@Model(type=TimesheetConfig::class)) * ) * * @Rest\Get(path="/config/timesheet") diff --git a/src/API/CustomerController.php b/src/API/CustomerController.php index f9377953..117239e9 100644 --- a/src/API/CustomerController.php +++ b/src/API/CustomerController.php @@ -42,6 +42,11 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; */ class CustomerController extends BaseApiController { + public const GROUPS_ENTITY = ['Default', 'Entity', 'Customer', 'Customer_Entity']; + public const GROUPS_FORM = ['Default', 'Entity', 'Customer']; + public const GROUPS_COLLECTION = ['Default', 'Collection', 'Customer']; + public const GROUPS_RATE = ['Default', 'Entity', 'Customer_Rate']; + /** * @var CustomerRepository */ @@ -112,7 +117,7 @@ class CustomerController extends BaseApiController $data = $this->repository->getCustomersForQuery($query); $view = new View($data, 200); - $view->getContext()->setGroups(['Default', 'Collection', 'Customer']); + $view->getContext()->setGroups(self::GROUPS_COLLECTION); return $this->viewHandler->handle($view); } @@ -138,7 +143,7 @@ class CustomerController extends BaseApiController } $view = new View($data, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Customer']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -185,13 +190,13 @@ class CustomerController extends BaseApiController $this->repository->saveCustomer($customer); $view = new View($customer, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Customer']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } $view = new View($form); - $view->getContext()->setGroups(['Default', 'Entity', 'Customer']); + $view->getContext()->setGroups(self::GROUPS_FORM); return $this->viewHandler->handle($view); } @@ -248,7 +253,7 @@ class CustomerController extends BaseApiController if (false === $form->isValid()) { $view = new View($form, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'Customer']); + $view->getContext()->setGroups(self::GROUPS_FORM); return $this->viewHandler->handle($view); } @@ -256,7 +261,7 @@ class CustomerController extends BaseApiController $this->repository->saveCustomer($customer); $view = new View($customer, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'Customer']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -309,7 +314,7 @@ class CustomerController extends BaseApiController $this->repository->saveCustomer($customer); $view = new View($customer, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Customer']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -352,7 +357,7 @@ class CustomerController extends BaseApiController $rates = $this->customerRateRepository->getRatesForCustomer($customer); $view = new View($rates, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'CustomerRate']); + $view->getContext()->setGroups(self::GROUPS_RATE); return $this->viewHandler->handle($view); } @@ -463,7 +468,7 @@ class CustomerController extends BaseApiController if (false === $form->isValid()) { $view = new View($form, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'CustomerRate']); + $view->getContext()->setGroups(self::GROUPS_RATE); return $this->viewHandler->handle($view); } @@ -471,7 +476,7 @@ class CustomerController extends BaseApiController $this->customerRateRepository->saveRate($rate); $view = new View($rate, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'CustomerRate']); + $view->getContext()->setGroups(self::GROUPS_RATE); return $this->viewHandler->handle($view); } diff --git a/src/API/Model/I18n.php b/src/API/Model/I18nConfig.php similarity index 50% rename from src/API/Model/I18n.php rename to src/API/Model/I18nConfig.php index b99d896a..c208960e 100644 --- a/src/API/Model/I18n.php +++ b/src/API/Model/I18nConfig.php @@ -11,177 +11,127 @@ declare(strict_types=1); namespace App\API\Model; -final class I18n +use JMS\Serializer\Annotation as Serializer; + +/** + * @Serializer\ExclusionPolicy("all") + */ +final class I18nConfig { /** * Format used for 'begin' and 'end' * * @var string + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="string") */ private $formDateTime = ''; /** * Format used for toolbar queries * * @var string + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="string") */ private $formDate = ''; /** * Format used to display date-time values (see PHP function date_format) * * @var string + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="string") */ private $dateTime = ''; /** * Format used to display date values (see PHP function date_format) * * @var string + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="string") */ private $date = ''; /** * Format used to display times (see PHP function date_format) * * @var string + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="string") */ private $time = ''; /** * Format used to display durations (replace: %h with hours, %m with minutes, %s with seconds) * * @var string + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="string") */ private $duration = ''; /** * Whether a twenty-four hour format is used (true) or 12-hours AM/PM format (false) + * * @var bool + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="boolean") */ private $is24hours = true; - /** - * @return string - */ - public function getFormDateTime(): string - { - return $this->formDateTime; - } - - /** - * @param string $formDateTime - * @return I18n - */ - public function setFormDateTime(string $formDateTime) + public function setFormDateTime(string $formDateTime): I18nConfig { $this->formDateTime = $formDateTime; return $this; } - /** - * @return string - */ - public function getFormDate(): string - { - return $this->formDate; - } - - /** - * @param string $formDate - * @return I18n - */ - public function setFormDate(string $formDate) + public function setFormDate(string $formDate): I18nConfig { $this->formDate = $formDate; return $this; } - /** - * @return string - */ - public function getDateTime(): string - { - return $this->dateTime; - } - - /** - * @param string $dateTime - * @return I18n - */ - public function setDateTime(string $dateTime) + public function setDateTime(string $dateTime): I18nConfig { $this->dateTime = $dateTime; return $this; } - /** - * @return string - */ - public function getDate(): string - { - return $this->date; - } - - /** - * @param string $date - * @return I18n - */ - public function setDate(string $date) + public function setDate(string $date): I18nConfig { $this->date = $date; return $this; } - /** - * @return string - */ - public function getDuration(): string - { - return $this->duration; - } - - /** - * @param string $duration - * @return I18n - */ - public function setDuration(string $duration) + public function setDuration(string $duration): I18nConfig { $this->duration = $duration; return $this; } - /** - * @return string - */ - public function getTime(): string - { - return $this->time; - } - - /** - * @param string $time - * @return I18n - */ - public function setTime(string $time) + public function setTime(string $time): I18nConfig { $this->time = $time; return $this; } - /** - * @return bool - */ - public function isIs24hours(): bool - { - return $this->is24hours; - } - - /** - * @param bool $is24hours - * @return I18n - */ - public function setIs24hours(bool $is24hours) + public function setIs24hours(bool $is24hours): I18nConfig { $this->is24hours = $is24hours; diff --git a/src/API/Model/TimesheetConfig.php b/src/API/Model/TimesheetConfig.php index 0db48377..0587642d 100644 --- a/src/API/Model/TimesheetConfig.php +++ b/src/API/Model/TimesheetConfig.php @@ -11,53 +11,74 @@ declare(strict_types=1); namespace App\API\Model; +use JMS\Serializer\Annotation as Serializer; + +/** + * @Serializer\ExclusionPolicy("none") + */ final class TimesheetConfig { /** - * See here: https://www.kimai.org/documentation/timesheet.html#tracking-modes + * The time-tracking mode, see also: https://www.kimai.org/documentation/timesheet.html#tracking-modes * * @var string + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="string") */ private $trackingMode = 'default'; /** * Default begin datetime in PHP format * * @var string + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="string") */ private $defaultBeginTime = 'now'; /** * How many running timesheets a user is allowed to have at the same time * * @var int + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="integer") */ private $activeEntriesHardLimit = 1; /** * How many running timesheets a user is allowed before a warning is shown * * @var int + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="integer") */ private $activeEntriesSoftLimit = 1; /** * Whether entries for future times are allowed * * @var bool + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="boolean") */ private $isAllowFutureTimes = true; /** * Whether overlapping entries are allowed * * @var bool + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="boolean") */ private $isAllowOverlapping = true; - /** - * @return string - */ - public function getTrackingMode(): string - { - return $this->trackingMode; - } - public function setTrackingMode(string $trackingMode): TimesheetConfig { $this->trackingMode = $trackingMode; @@ -65,11 +86,6 @@ final class TimesheetConfig return $this; } - public function getDefaultBeginTime(): string - { - return $this->defaultBeginTime; - } - public function setDefaultBeginTime(string $defaultBeginTime): TimesheetConfig { $this->defaultBeginTime = $defaultBeginTime; @@ -77,11 +93,6 @@ final class TimesheetConfig return $this; } - public function getActiveEntriesHardLimit(): int - { - return $this->activeEntriesHardLimit; - } - public function setActiveEntriesHardLimit(int $activeEntriesHardLimit): TimesheetConfig { $this->activeEntriesHardLimit = $activeEntriesHardLimit; @@ -89,11 +100,6 @@ final class TimesheetConfig return $this; } - public function getActiveEntriesSoftLimit(): int - { - return $this->activeEntriesSoftLimit; - } - public function setActiveEntriesSoftLimit(int $activeEntriesSoftLimit): TimesheetConfig { $this->activeEntriesSoftLimit = $activeEntriesSoftLimit; @@ -101,11 +107,6 @@ final class TimesheetConfig return $this; } - public function isAllowFutureTimes(): bool - { - return $this->isAllowFutureTimes; - } - public function setIsAllowFutureTimes(bool $isAllowFutureTimes): TimesheetConfig { $this->isAllowFutureTimes = $isAllowFutureTimes; @@ -113,11 +114,6 @@ final class TimesheetConfig return $this; } - public function isAllowOverlapping(): bool - { - return $this->isAllowOverlapping; - } - public function setIsAllowOverlapping(bool $isAllowOverlapping): TimesheetConfig { $this->isAllowOverlapping = $isAllowOverlapping; diff --git a/src/API/Model/Version.php b/src/API/Model/Version.php index fa7fe701..178c30d6 100644 --- a/src/API/Model/Version.php +++ b/src/API/Model/Version.php @@ -12,37 +12,61 @@ declare(strict_types=1); namespace App\API\Model; use App\Constants; +use JMS\Serializer\Annotation as Serializer; +/** + * @Serializer\ExclusionPolicy("all") + */ class Version { /** * Kimai Version, eg. "1.9" * * @var string + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="string") */ protected $version = Constants::VERSION; /** * Candidate: either "prod" or "dev" * * @var string + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="string") */ protected $candidate = Constants::STATUS; /** * Full version including status, eg: "1.9-prod" * * @var string + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="string") */ protected $semver = Constants::VERSION . '-' . Constants::STATUS; /** * The version name * * @var string + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="string") */ protected $name = Constants::NAME; /** * A full copyright notice * * @var string + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="string") */ protected $copyright = Constants::SOFTWARE . ' - ' . Constants::VERSION . ' ' . Constants::STATUS . ' (' . Constants::NAME . ') by Kevin Papst and contributors.'; } diff --git a/src/API/ProjectController.php b/src/API/ProjectController.php index d691013e..a32f9ade 100644 --- a/src/API/ProjectController.php +++ b/src/API/ProjectController.php @@ -45,6 +45,11 @@ use Symfony\Component\Validator\Constraints; */ class ProjectController extends BaseApiController { + public const GROUPS_ENTITY = ['Default', 'Entity', 'Project', 'Project_Entity']; + public const GROUPS_FORM = ['Default', 'Entity', 'Project']; + public const GROUPS_COLLECTION = ['Default', 'Collection', 'Project']; + public const GROUPS_RATE = ['Default', 'Entity', 'Project_Rate']; + /** * @var ProjectRepository */ @@ -159,7 +164,7 @@ class ProjectController extends BaseApiController $data = $this->repository->getProjectsForQuery($query); $view = new View($data, 200); - $view->getContext()->setGroups(['Default', 'Collection', 'Project']); + $view->getContext()->setGroups(self::GROUPS_COLLECTION); return $this->viewHandler->handle($view); } @@ -185,7 +190,7 @@ class ProjectController extends BaseApiController } $view = new View($data, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Project']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -233,13 +238,13 @@ class ProjectController extends BaseApiController $this->repository->saveProject($project); $view = new View($project, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Project']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } $view = new View($form); - $view->getContext()->setGroups(['Default', 'Entity', 'Project']); + $view->getContext()->setGroups(self::GROUPS_FORM); return $this->viewHandler->handle($view); } @@ -297,7 +302,7 @@ class ProjectController extends BaseApiController if (false === $form->isValid()) { $view = new View($form, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'Project']); + $view->getContext()->setGroups(self::GROUPS_FORM); return $this->viewHandler->handle($view); } @@ -305,7 +310,7 @@ class ProjectController extends BaseApiController $this->repository->saveProject($project); $view = new View($project, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'Project']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -358,7 +363,7 @@ class ProjectController extends BaseApiController $this->repository->saveProject($project); $view = new View($project, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Project']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -401,7 +406,7 @@ class ProjectController extends BaseApiController $rates = $this->projectRateRepository->getRatesForProject($project); $view = new View($rates, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'ProjectRate']); + $view->getContext()->setGroups(self::GROUPS_RATE); return $this->viewHandler->handle($view); } @@ -512,7 +517,7 @@ class ProjectController extends BaseApiController if (false === $form->isValid()) { $view = new View($form, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'ProjectRate']); + $view->getContext()->setGroups(self::GROUPS_RATE); return $this->viewHandler->handle($view); } @@ -520,7 +525,7 @@ class ProjectController extends BaseApiController $this->projectRateRepository->saveRate($rate); $view = new View($rate, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'ProjectRate']); + $view->getContext()->setGroups(self::GROUPS_RATE); return $this->viewHandler->handle($view); } diff --git a/src/API/StatusController.php b/src/API/StatusController.php index 25c8db2a..709ace8d 100644 --- a/src/API/StatusController.php +++ b/src/API/StatusController.php @@ -28,11 +28,8 @@ class StatusController extends BaseApiController /** * @var ViewHandlerInterface */ - protected $viewHandler; + private $viewHandler; - /** - * @param ViewHandlerInterface $viewHandler - */ public function __construct(ViewHandlerInterface $viewHandler) { $this->viewHandler = $viewHandler; diff --git a/src/API/TagController.php b/src/API/TagController.php index 2d48989d..dbedb1f8 100644 --- a/src/API/TagController.php +++ b/src/API/TagController.php @@ -32,22 +32,21 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; * * @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')") */ -class TagController extends BaseApiController +final class TagController extends BaseApiController { + public const GROUPS_COLLECTION = ['Default', 'Collection', 'Tag']; + public const GROUPS_ENTITY = ['Default', 'Entity', 'Tag']; + public const GROUPS_FORM = ['Default', 'Entity', 'Tag']; + /** * @var TagRepository */ - protected $repository; - + private $repository; /** * @var ViewHandlerInterface */ - protected $viewHandler; + private $viewHandler; - /** - * @param ViewHandlerInterface $viewHandler - * @param TagRepository $repository - */ public function __construct(ViewHandlerInterface $viewHandler, TagRepository $repository) { $this->viewHandler = $viewHandler; @@ -78,7 +77,7 @@ class TagController extends BaseApiController $data = $this->repository->findAllTagNames($filter); $view = new View($data, 200); - $view->getContext()->setGroups(['Default', 'Collection', 'Tag']); + $view->getContext()->setGroups(self::GROUPS_COLLECTION); return $this->viewHandler->handle($view); } @@ -120,13 +119,13 @@ class TagController extends BaseApiController $this->repository->saveTag($tag); $view = new View($tag, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Tag']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } $view = new View($form); - $view->getContext()->setGroups(['Default', 'Entity', 'Tag']); + $view->getContext()->setGroups(self::GROUPS_FORM); return $this->viewHandler->handle($view); } @@ -137,7 +136,7 @@ class TagController extends BaseApiController * @SWG\Delete( * @SWG\Response( * response=204, - * description="Delete one tag" + * description="HTTP code 204 for a successful delete" * ), * ) * @SWG\Parameter( diff --git a/src/API/TeamController.php b/src/API/TeamController.php index 252af08b..f075e98b 100644 --- a/src/API/TeamController.php +++ b/src/API/TeamController.php @@ -39,6 +39,10 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; */ final class TeamController extends BaseApiController { + public const GROUPS_ENTITY = ['Default', 'Entity', 'Team', 'Team_Entity']; + public const GROUPS_FORM = ['Default', 'Entity', 'Team', 'Team_Entity']; + public const GROUPS_COLLECTION = ['Default', 'Collection', 'Team']; + /** * @var TeamRepository */ @@ -76,7 +80,7 @@ final class TeamController extends BaseApiController $data = $this->repository->findAll(); $view = new View($data, 200); - $view->getContext()->setGroups(['Default', 'Collection', 'Team']); + $view->getContext()->setGroups(self::GROUPS_COLLECTION); return $this->viewHandler->handle($view); } @@ -104,7 +108,7 @@ final class TeamController extends BaseApiController } $view = new View($data, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Team', 'Team_Entity']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -154,7 +158,7 @@ final class TeamController extends BaseApiController * @SWG\Response( * response=200, * description="Returns the new created team", - * @SWG\Schema(ref="#/definitions/TeamEntity",), + * @SWG\Schema(ref="#/definitions/TeamEntity"), * ) * ) * @SWG\Parameter( @@ -186,13 +190,13 @@ final class TeamController extends BaseApiController $this->repository->saveTeam($team); $view = new View($team, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } $view = new View($form); - $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + $view->getContext()->setGroups(self::GROUPS_FORM); return $this->viewHandler->handle($view); } @@ -243,7 +247,7 @@ final class TeamController extends BaseApiController if (false === $form->isValid()) { $view = new View($form, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + $view->getContext()->setGroups(self::GROUPS_FORM); return $this->viewHandler->handle($view); } @@ -251,7 +255,7 @@ final class TeamController extends BaseApiController $this->repository->saveTeam($team); $view = new View($team, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -314,7 +318,7 @@ final class TeamController extends BaseApiController $this->repository->saveTeam($team); $view = new View($team, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -377,7 +381,7 @@ final class TeamController extends BaseApiController $this->repository->saveTeam($team); $view = new View($team, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -440,7 +444,7 @@ final class TeamController extends BaseApiController $this->repository->saveTeam($team); $view = new View($team, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -499,7 +503,7 @@ final class TeamController extends BaseApiController $this->repository->saveTeam($team); $view = new View($team, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -562,7 +566,7 @@ final class TeamController extends BaseApiController $this->repository->saveTeam($team); $view = new View($team, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -621,7 +625,7 @@ final class TeamController extends BaseApiController $this->repository->saveTeam($team); $view = new View($team, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } diff --git a/src/API/TimesheetController.php b/src/API/TimesheetController.php index 602ea307..57b346cd 100644 --- a/src/API/TimesheetController.php +++ b/src/API/TimesheetController.php @@ -51,6 +51,11 @@ use Symfony\Component\Validator\Validator\ValidatorInterface; */ class TimesheetController extends BaseApiController { + public const GROUPS_ENTITY = ['Default', 'Entity', 'Timesheet', 'Timesheet_Entity', 'Not_Expanded']; + public const GROUPS_FORM = ['Default', 'Entity', 'Timesheet', 'Not_Expanded']; + public const GROUPS_COLLECTION = ['Default', 'Collection', 'Timesheet', 'Not_Expanded']; + public const GROUPS_COLLECTION_FULL = ['Default', 'Collection', 'Timesheet', 'Expanded']; + /** * @var TimesheetRepository */ @@ -266,9 +271,9 @@ class TimesheetController extends BaseApiController $view = new View($data, 200); if ('true' === $paramFetcher->get('full')) { - $view->getContext()->setGroups(['Default', 'Subresource', 'Timesheet']); + $view->getContext()->setGroups(self::GROUPS_COLLECTION_FULL); } else { - $view->getContext()->setGroups(['Default', 'Collection', 'Timesheet']); + $view->getContext()->setGroups(self::GROUPS_COLLECTION); } return $this->viewHandler->handle($view); @@ -308,7 +313,7 @@ class TimesheetController extends BaseApiController } $view = new View($data, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -368,13 +373,13 @@ class TimesheetController extends BaseApiController } $view = new View($timesheet, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } $view = new View($form); - $view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']); + $view->getContext()->setGroups(self::GROUPS_FORM); return $this->viewHandler->handle($view); } @@ -438,7 +443,7 @@ class TimesheetController extends BaseApiController if (false === $form->isValid()) { $view = new View($form, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']); + $view->getContext()->setGroups(self::GROUPS_FORM); return $this->viewHandler->handle($view); } @@ -446,7 +451,7 @@ class TimesheetController extends BaseApiController $this->service->updateTimesheet($timesheet); $view = new View($timesheet, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -498,7 +503,7 @@ class TimesheetController extends BaseApiController * description="Returns the collection of recent user activities (always the latest entry of a unique working set grouped by customer, project and activity)", * @SWG\Schema( * type="array", - * @SWG\Items(ref="#/definitions/TimesheetSubCollection") + * @SWG\Items(ref="#/definitions/TimesheetCollectionExpanded") * ) * ) * @@ -535,7 +540,7 @@ class TimesheetController extends BaseApiController $data = $this->repository->getRecentActivities($user, $begin, $limit); $view = new View($data, 200); - $view->getContext()->setGroups(['Default', 'Subresource', 'Timesheet']); + $view->getContext()->setGroups(self::GROUPS_COLLECTION_FULL); return $this->viewHandler->handle($view); } @@ -548,7 +553,7 @@ class TimesheetController extends BaseApiController * description="Returns the collection of active timesheet records for the current user", * @SWG\Schema( * type="array", - * @SWG\Items(ref="#/definitions/TimesheetSubCollection") + * @SWG\Items(ref="#/definitions/TimesheetCollectionExpanded") * ) * ) * @@ -565,7 +570,7 @@ class TimesheetController extends BaseApiController $data = $this->repository->getActiveEntries($user); $view = new View($data, 200); - $view->getContext()->setGroups(['Default', 'Subresource', 'Timesheet']); + $view->getContext()->setGroups(self::GROUPS_COLLECTION_FULL); return $this->viewHandler->handle($view); } @@ -604,7 +609,7 @@ class TimesheetController extends BaseApiController $this->service->stopTimesheet($timesheet); $view = new View($timesheet, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -687,7 +692,7 @@ class TimesheetController extends BaseApiController $this->service->saveNewTimesheet($copyTimesheet); $view = new View($copyTimesheet, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -728,7 +733,7 @@ class TimesheetController extends BaseApiController $this->service->saveNewTimesheet($copyTimesheet); $view = new View($copyTimesheet, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -771,7 +776,7 @@ class TimesheetController extends BaseApiController $this->service->updateTimesheet($timesheet); $view = new View($timesheet, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -824,7 +829,7 @@ class TimesheetController extends BaseApiController $this->service->updateTimesheet($timesheet); $view = new View($timesheet, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } diff --git a/src/API/UserController.php b/src/API/UserController.php index 5ec1f8af..18dfefe1 100644 --- a/src/API/UserController.php +++ b/src/API/UserController.php @@ -39,6 +39,10 @@ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; */ final class UserController extends BaseApiController { + public const GROUPS_ENTITY = ['Default', 'Entity', 'User', 'User_Entity']; + public const GROUPS_FORM = ['Default', 'Entity', 'User', 'User_Entity']; + public const GROUPS_COLLECTION = ['Default', 'Collection', 'User']; + /** * @var UserRepository */ @@ -56,10 +60,6 @@ final class UserController extends BaseApiController */ private $configuration; - /** - * @param ViewHandlerInterface $viewHandler - * @param UserRepository $repository - */ public function __construct(ViewHandlerInterface $viewHandler, UserRepository $repository, UserPasswordEncoderInterface $encoder, FormConfiguration $config) { $this->viewHandler = $viewHandler; @@ -113,7 +113,7 @@ final class UserController extends BaseApiController $data = $this->repository->getUsersForQuery($query); $view = new View($data, 200); - $view->getContext()->setGroups(['Default', 'Collection', 'User']); + $view->getContext()->setGroups(self::GROUPS_COLLECTION); return $this->viewHandler->handle($view); } @@ -150,7 +150,7 @@ final class UserController extends BaseApiController } $view = new View($user, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'User', 'User_Entity']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -172,7 +172,7 @@ final class UserController extends BaseApiController public function meAction(): Response { $view = new View($this->getUser(), 200); - $view->getContext()->setGroups(['Default', 'Entity', 'User', 'User_Entity']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } @@ -223,13 +223,13 @@ final class UserController extends BaseApiController $this->repository->saveUser($user); $view = new View($user, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'User', 'User_Entity']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } $view = new View($form); - $view->getContext()->setGroups(['Default', 'Entity', 'User', 'User_Entity']); + $view->getContext()->setGroups(self::GROUPS_FORM); return $this->viewHandler->handle($view); } @@ -285,7 +285,7 @@ final class UserController extends BaseApiController if (false === $form->isValid()) { $view = new View($form, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'User', 'User_Entity']); + $view->getContext()->setGroups(self::GROUPS_FORM); return $this->viewHandler->handle($view); } @@ -293,7 +293,7 @@ final class UserController extends BaseApiController $this->repository->saveUser($user); $view = new View($user, Response::HTTP_OK); - $view->getContext()->setGroups(['Default', 'Entity', 'User', 'User_Entity']); + $view->getContext()->setGroups(self::GROUPS_ENTITY); return $this->viewHandler->handle($view); } diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index bcfa1582..abb4e231 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -363,7 +363,6 @@ class ProfileController extends AbstractController UserPasswordType::class, $user, [ - 'validation_groups' => ['PasswordUpdate'], 'action' => $this->generateUrl('user_profile_password', ['username' => $user->getUsername()]), 'method' => 'POST' ] diff --git a/src/Entity/Activity.php b/src/Entity/Activity.php index 56f1335c..030cfff7 100644 --- a/src/Entity/Activity.php +++ b/src/Entity/Activity.php @@ -12,27 +12,49 @@ namespace App\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use JMS\Serializer\Annotation as Serializer; use Symfony\Component\Validator\Constraints as Assert; /** * @ORM\Table(name="kimai2_activities", - * indexes={ + * indexes={ * @ORM\Index(columns={"visible","project_id"}), * @ORM\Index(columns={"visible","project_id","name"}), * @ORM\Index(columns={"visible","name"}) - * } + * } * ) * @ORM\Entity(repositoryClass="App\Repository\ActivityRepository") * - * columns={"visible","name"} => IDX_8811FE1C7AB0E8595E237E06 => activity administration without filter - * columns={"visible","project_id"} => IDX_8811FE1C7AB0E859166D1F9C => activity administration with customer or project filter - * columns={"visible","project_id","name"} => IDX_8811FE1C7AB0E859166D1F9C5E237E06 => activity drop-down for global activities in toolbar or globalsOnly filter in activity administration + * @Serializer\ExclusionPolicy("all") + * @Serializer\VirtualProperty( + * "ProjectName", + * exp="object.getProject() === null ? null : object.getProject().getName()", + * options={ + * @Serializer\SerializedName("parentTitle"), + * @Serializer\Type(name="string"), + * @Serializer\Groups({"Activity"}) + * } + * ) + * @Serializer\VirtualProperty( + * "ProjectAsId", + * exp="object.getProject() === null ? null : object.getProject().getId()", + * options={ + * @Serializer\SerializedName("project"), + * @Serializer\Type(name="integer"), + * @Serializer\Groups({"Default"}) + * } + * ) */ class Activity implements EntityWithMetaFields { /** + * Internal ID + * * @var int|null * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="id", type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="IDENTITY") @@ -46,34 +68,82 @@ class Activity implements EntityWithMetaFields */ private $project; /** + * Name of this activity + * * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="name", type="string", length=150, nullable=false) * @Assert\NotBlank() * @Assert\Length(min=2, max=150, allowEmptyString=false) */ private $name; /** + * Description of this activity + * * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Activity_Entity"}) + * * @ORM\Column(name="comment", type="text", nullable=true) */ private $comment; /** + * Whether this activity is visible and can be used for timesheets + * * @var bool * - * @ORM\Column(name="visible", type="boolean", nullable=false) + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * + * @ORM\Column(name="visible", type="boolean", nullable=false, options={"default": true}) * @Assert\NotNull() */ private $visible = true; - // keep the trait include exactly here, for placing the column at the correct position + // keep the traits here, for placing the column at the "correct" position use ColorTrait; - use BudgetTrait; /** + * The total monetary budget, will be zero if unconfigured. + * + * @var float + * + * @Serializer\Expose() + * @Serializer\Groups({"Activity_Entity"}) + * + * @ORM\Column(name="budget", type="float", nullable=false) + * @Assert\NotNull() + */ + private $budget = 0.00; + /** + * The time budget in seconds, will be be zero if unconfigured. + * + * @var int + * + * @Serializer\Expose() + * @Serializer\Groups({"Activity_Entity"}) + * + * @ORM\Column(name="time_budget", type="integer", nullable=false) + * @Assert\NotNull() + */ + private $timeBudget = 0; + /** + * Meta fields + * + * All visible meta (custom) fields registered with this activity + * * @var ActivityMeta[]|Collection * + * @Serializer\Expose() + * @Serializer\Groups({"Activity"}) + * @Serializer\Type(name="array") + * @Serializer\SerializedName("metaFields") + * @Serializer\Accessor(getter="getVisibleMetaFields") + * * @ORM\OneToMany(targetEntity="App\Entity\ActivityMeta", mappedBy="activity", cascade={"persist"}) */ private $meta; @@ -141,6 +211,30 @@ class Activity implements EntityWithMetaFields return $this->visible; } + public function setBudget(float $budget): Activity + { + $this->budget = $budget; + + return $this; + } + + public function getBudget(): float + { + return $this->budget; + } + + public function setTimeBudget(int $seconds): Activity + { + $this->timeBudget = $seconds; + + return $this; + } + + public function getTimeBudget(): int + { + return $this->timeBudget; + } + /** * @return Collection|MetaTableTypeInterface[] */ diff --git a/src/Entity/ActivityMeta.php b/src/Entity/ActivityMeta.php index c52d541b..9acf5d6c 100644 --- a/src/Entity/ActivityMeta.php +++ b/src/Entity/ActivityMeta.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace App\Entity; use Doctrine\ORM\Mapping as ORM; +use JMS\Serializer\Annotation as Serializer; use Symfony\Component\Validator\Constraints as Assert; /** @@ -21,6 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert; * @ORM\UniqueConstraint(columns={"activity_id", "name"}) * } * ) + * @Serializer\ExclusionPolicy("all") */ class ActivityMeta implements MetaTableTypeInterface { diff --git a/src/Entity/ActivityRate.php b/src/Entity/ActivityRate.php index ad96bb94..dc4b1ecd 100644 --- a/src/Entity/ActivityRate.php +++ b/src/Entity/ActivityRate.php @@ -10,6 +10,7 @@ namespace App\Entity; use Doctrine\ORM\Mapping as ORM; +use JMS\Serializer\Annotation as Serializer; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Validator\Constraints as Assert; @@ -21,6 +22,8 @@ use Symfony\Component\Validator\Constraints as Assert; * ) * @ORM\Entity(repositoryClass="App\Repository\ActivityRateRepository") * @UniqueEntity({"user", "activity"}, ignoreNull=false) + * + * @Serializer\ExclusionPolicy("all") */ class ActivityRate implements RateInterface { @@ -29,6 +32,8 @@ class ActivityRate implements RateInterface /** * @var Activity * + * @Serializer\Exclude() + * * @ORM\ManyToOne(targetEntity="App\Entity\Activity") * @ORM\JoinColumn(onDelete="CASCADE", nullable=false) * @Assert\NotNull() diff --git a/src/Entity/BudgetTrait.php b/src/Entity/BudgetTrait.php deleted file mode 100644 index d4c9b808..00000000 --- a/src/Entity/BudgetTrait.php +++ /dev/null @@ -1,69 +0,0 @@ -budget = $budget; - - return $this; - } - - /** - * @return float - */ - public function getBudget() - { - return $this->budget; - } - - /** - * @param int $seconds - * @return self - */ - public function setTimeBudget(?int $seconds) - { - $this->timeBudget = $seconds; - - return $this; - } - - public function getTimeBudget(): int - { - return $this->timeBudget; - } -} diff --git a/src/Entity/ColorTrait.php b/src/Entity/ColorTrait.php index 41b6d34e..df247420 100644 --- a/src/Entity/ColorTrait.php +++ b/src/Entity/ColorTrait.php @@ -10,13 +10,21 @@ namespace App\Entity; use Doctrine\ORM\Mapping as ORM; +use JMS\Serializer\Annotation as Serializer; +use Symfony\Component\Validator\Constraints as Assert; trait ColorTrait { /** + * The assigned color in HTML hex format, eg. #dd1d00 + * * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="color", type="string", length=7, nullable=true) + * @Assert\Length(min=4, max=7) */ private $color = null; diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index 449a6df0..fdbc6f62 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -12,6 +12,8 @@ namespace App\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use JMS\Serializer\Annotation as Serializer; +use Swagger\Annotations as SWG; use Symfony\Component\Validator\Constraints as Assert; /** @@ -22,14 +24,17 @@ use Symfony\Component\Validator\Constraints as Assert; * ) * @ORM\Entity(repositoryClass="App\Repository\CustomerRepository") * - * columns={"visible"} => IDX_5A9760447AB0E859 => used in customer dropdown + * @Serializer\ExclusionPolicy("all") */ class Customer implements EntityWithMetaFields { public const DEFAULT_CURRENCY = 'EUR'; /** - * @var int + * @var int|null + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) * * @ORM\Column(name="id", type="integer") * @ORM\Id @@ -39,6 +44,9 @@ class Customer implements EntityWithMetaFields /** * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="name", type="string", length=150, nullable=false) * @Assert\NotBlank() * @Assert\Length(min=2, max=150, allowEmptyString=false) @@ -47,6 +55,9 @@ class Customer implements EntityWithMetaFields /** * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Customer_Entity"}) + * * @ORM\Column(name="number", type="string", length=50, nullable=true) * @Assert\Length(max=50) */ @@ -54,12 +65,18 @@ class Customer implements EntityWithMetaFields /** * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Customer_Entity"}) + * * @ORM\Column(name="comment", type="text", nullable=true) */ private $comment; /** * @var bool * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="visible", type="boolean", nullable=false) * @Assert\NotNull() */ @@ -67,6 +84,9 @@ class Customer implements EntityWithMetaFields /** * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Customer_Entity"}) + * * @ORM\Column(name="company", type="string", length=255, nullable=true) * @Assert\Length(max=255) */ @@ -74,6 +94,9 @@ class Customer implements EntityWithMetaFields /** * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Customer_Entity"}) + * * @ORM\Column(name="vat_id", type="string", length=50, nullable=true) * @Assert\Length(max=50) */ @@ -81,6 +104,9 @@ class Customer implements EntityWithMetaFields /** * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Customer_Entity"}) + * * @ORM\Column(name="contact", type="string", length=255, nullable=true) * @Assert\Length(max=255) */ @@ -88,12 +114,18 @@ class Customer implements EntityWithMetaFields /** * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Customer_Entity"}) + * * @ORM\Column(name="address", type="text", nullable=true) */ private $address; /** * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Customer_Entity"}) + * * @ORM\Column(name="country", type="string", length=2, nullable=false) * @Assert\NotBlank() * @Assert\Length(max=2) @@ -102,6 +134,9 @@ class Customer implements EntityWithMetaFields /** * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Customer"}) + * * @ORM\Column(name="currency", type="string", length=3, nullable=false) * @Assert\NotBlank() * @Assert\Length(max=3) @@ -110,6 +145,9 @@ class Customer implements EntityWithMetaFields /** * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Customer_Entity"}) + * * @ORM\Column(name="phone", type="string", length=255, nullable=true) * @Assert\Length(max=255) */ @@ -117,6 +155,9 @@ class Customer implements EntityWithMetaFields /** * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Customer_Entity"}) + * * @ORM\Column(name="fax", type="string", length=255, nullable=true) * @Assert\Length(max=255) */ @@ -124,15 +165,23 @@ class Customer implements EntityWithMetaFields /** * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Customer_Entity"}) + * * @ORM\Column(name="mobile", type="string", length=255, nullable=true) * @Assert\Length(max=255) */ private $mobile; /** - * @var string + * Customers contact email * * Limited via RFC to 254 chars * + * @var string + * + * @Serializer\Expose() + * @Serializer\Groups({"Customer_Entity"}) + * * @ORM\Column(name="email", type="string", length=255, nullable=true) * @Assert\Length(max=254) */ @@ -140,15 +189,23 @@ class Customer implements EntityWithMetaFields /** * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Customer_Entity"}) + * * @ORM\Column(name="homepage", type="string", length=255, nullable=true) * @Assert\Length(max=255) */ private $homepage; /** - * @var string + * Timezone of begin and end * * Length was determined by a MySQL column via "use mysql;describe time_zone_name;" * + * @var string + * + * @Serializer\Expose() + * @Serializer\Groups({"Customer_Entity"}) + * * @ORM\Column(name="timezone", type="string", length=64, nullable=false) * @Assert\NotBlank() * @Assert\Length(max=64) @@ -157,17 +214,58 @@ class Customer implements EntityWithMetaFields // keep the trait include exactly here, for placing the column at the correct position use ColorTrait; - use BudgetTrait; /** + * The total monetary budget, will be zero if unconfigured. + * + * @var float + * + * @Serializer\Expose() + * @Serializer\Groups({"Customer_Entity"}) + * + * @ORM\Column(name="budget", type="float", nullable=false) + * @Assert\NotNull() + */ + private $budget = 0.00; + /** + * The time budget in seconds, will be be zero if unconfigured. + * + * @var int + * + * @Serializer\Expose() + * @Serializer\Groups({"Customer_Entity"}) + * + * @ORM\Column(name="time_budget", type="integer", nullable=false) + * @Assert\NotNull() + */ + private $timeBudget = 0; + /** + * Meta fields + * + * All visible meta (custom) fields registered with this customer + * * @var CustomerMeta[]|Collection * + * @Serializer\Expose() + * @Serializer\Groups({"Customer"}) + * @Serializer\Type(name="array") + * @Serializer\SerializedName("metaFields") + * @Serializer\Accessor(getter="getVisibleMetaFields") + * * @ORM\OneToMany(targetEntity="App\Entity\CustomerMeta", mappedBy="customer", cascade={"persist"}) */ private $meta; /** + * Teams + * + * If no team is assigned, everyone can access the customer + * * @var Team[]|ArrayCollection * + * @Serializer\Expose() + * @Serializer\Groups({"Customer"}) + * @SWG\Property(type="array", @SWG\Items(ref="#/definitions/Team")) + * * @ORM\ManyToMany(targetEntity="Team", cascade={"persist"}, inversedBy="customers") * @ORM\JoinTable( * name="kimai2_customers_teams", @@ -384,6 +482,30 @@ class Customer implements EntityWithMetaFields return $this->timezone; } + public function setBudget(float $budget): Customer + { + $this->budget = $budget; + + return $this; + } + + public function getBudget(): float + { + return $this->budget; + } + + public function setTimeBudget(int $seconds): Customer + { + $this->timeBudget = $seconds; + + return $this; + } + + public function getTimeBudget(): int + { + return $this->timeBudget; + } + /** * @return Collection|MetaTableTypeInterface[] */ diff --git a/src/Entity/CustomerMeta.php b/src/Entity/CustomerMeta.php index 96e3f2b1..0ff913f4 100644 --- a/src/Entity/CustomerMeta.php +++ b/src/Entity/CustomerMeta.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace App\Entity; use Doctrine\ORM\Mapping as ORM; +use JMS\Serializer\Annotation as Serializer; use Symfony\Component\Validator\Constraints as Assert; /** @@ -21,6 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert; * @ORM\UniqueConstraint(columns={"customer_id", "name"}) * } * ) + * @Serializer\ExclusionPolicy("all") */ class CustomerMeta implements MetaTableTypeInterface { diff --git a/src/Entity/CustomerRate.php b/src/Entity/CustomerRate.php index e92c7d10..74b8f68a 100644 --- a/src/Entity/CustomerRate.php +++ b/src/Entity/CustomerRate.php @@ -10,6 +10,7 @@ namespace App\Entity; use Doctrine\ORM\Mapping as ORM; +use JMS\Serializer\Annotation as Serializer; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Validator\Constraints as Assert; @@ -21,6 +22,8 @@ use Symfony\Component\Validator\Constraints as Assert; * ) * @ORM\Entity(repositoryClass="App\Repository\CustomerRateRepository") * @UniqueEntity({"user", "customer"}, ignoreNull=false) + * + * @Serializer\ExclusionPolicy("all") */ class CustomerRate implements RateInterface { @@ -29,6 +32,8 @@ class CustomerRate implements RateInterface /** * @var Customer * + * @Serializer\Exclude() + * * @ORM\ManyToOne(targetEntity="App\Entity\Customer") * @ORM\JoinColumn(onDelete="CASCADE", nullable=false) * @Assert\NotNull() diff --git a/src/Entity/MetaTableTypeTrait.php b/src/Entity/MetaTableTypeTrait.php index 1dcdf9d1..803e8a10 100644 --- a/src/Entity/MetaTableTypeTrait.php +++ b/src/Entity/MetaTableTypeTrait.php @@ -11,6 +11,7 @@ namespace App\Entity; use App\Form\Type\YesNoType; use Doctrine\ORM\Mapping as ORM; +use JMS\Serializer\Annotation as Serializer; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Validator\Constraint; @@ -21,6 +22,8 @@ trait MetaTableTypeTrait /** * @var int|null * + * @Serializer\Exclude() + * * @ORM\Id * @ORM\GeneratedValue * @ORM\Column(name="id", type="integer") @@ -28,8 +31,13 @@ trait MetaTableTypeTrait private $id; /** + * Name of the meta (custom) field + * * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="name", type="string", length=50, nullable=false) * @Assert\NotNull() * @Assert\Length(min=2, max=50, allowEmptyString=false) @@ -37,8 +45,13 @@ trait MetaTableTypeTrait private $name; /** + * Value of the meta (custom) field + * * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="value", type="string", length=255, nullable=true) */ private $value; diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 14b3e3e3..5613a165 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -9,9 +9,12 @@ namespace App\Entity; +use App\Validator\Constraints as Constraints; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use JMS\Serializer\Annotation as Serializer; +use Swagger\Annotations as SWG; use Symfony\Component\Validator\Constraints as Assert; /** @@ -22,40 +25,78 @@ use Symfony\Component\Validator\Constraints as Assert; * } * ) * @ORM\Entity(repositoryClass="App\Repository\ProjectRepository") - * @App\Validator\Constraints\Project + * @Constraints\Project * - * columns={"customer_id","visible","name"} => IDX_407F12069395C3F37AB0E8595E237E06 => project administration without filter - * columns={"customer_id","visible","id"} => IDX_407F12069395C3F37AB0E859BF396750 => used in joins between project and customer, eg. dropdowns and activity administration page + * @Serializer\ExclusionPolicy("all") + * @Serializer\VirtualProperty( + * "CustomerName", + * exp="object.getCustomer() === null ? null : object.getCustomer().getName()", + * options={ + * @Serializer\SerializedName("parentTitle"), + * @Serializer\Type(name="string"), + * @Serializer\Groups({"Project"}) + * } + * ) + * @Serializer\VirtualProperty( + * "CustomerAsId", + * exp="object.getCustomer() === null ? null : object.getCustomer().getId()", + * options={ + * @Serializer\SerializedName("customer"), + * @Serializer\Type(name="integer"), + * @Serializer\Groups({"Project", "Team", "Not_Expanded"}) + * } + * ) */ class Project implements EntityWithMetaFields { /** + * Internal ID + * * @var int|null * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="id", type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="IDENTITY") */ private $id; /** + * Customer for this project + * * @var Customer * + * @Serializer\Expose() + * @Serializer\Groups({"Subresource", "Expanded"}) + * @SWG\Property(type="array", @SWG\Items(ref="#/definitions/Customer")) + * * @ORM\ManyToOne(targetEntity="App\Entity\Customer") * @ORM\JoinColumn(onDelete="CASCADE", nullable=false) * @Assert\NotNull() */ private $customer; /** + * Project name + * * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="name", type="string", length=150, nullable=false) * @Assert\NotNull() * @Assert\Length(min=2, max=150, allowEmptyString=false) */ private $name; /** + * Project order number + * * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Project_Entity"}) + * * @ORM\Column(name="order_number", type="text", length=20, nullable=true) * @Assert\Length(max=20) */ @@ -63,40 +104,60 @@ class Project implements EntityWithMetaFields /** * @var \DateTime * + * @Serializer\Expose() + * @Serializer\Groups({"Project_Entity"}) + * @Serializer\Type(name="DateTime") + * * @ORM\Column(name="order_date", type="datetime", nullable=true) */ private $orderDate; /** * @var \DateTime * + * @Serializer\Expose() + * @Serializer\Groups({"Project"}) + * @Serializer\Type(name="DateTime") + * * @ORM\Column(name="start", type="datetime", nullable=true) */ private $start; /** * @var \DateTime * + * @Serializer\Expose() + * @Serializer\Groups({"Project"}) + * @Serializer\Type(name="DateTime") + * * @ORM\Column(name="end", type="datetime", nullable=true) */ private $end; /** * @var string + * @internal used for storing the timezone for "order", "start" and "end" date * * @ORM\Column(name="timezone", type="string", length=64, nullable=true) */ private $timezone; /** * @var bool + * @internal used for having the localization state of the dates (see $timezone) */ private $localized = false; /** * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Project_Entity"}) + * * @ORM\Column(name="comment", type="text", nullable=true) */ private $comment; /** * @var bool * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="visible", type="boolean", nullable=false) * @Assert\NotNull() */ @@ -104,18 +165,58 @@ class Project implements EntityWithMetaFields // keep the trait include exactly here, for placing the column at the correct position use ColorTrait; - use BudgetTrait; /** + * The total monetary budget, will be zero if unconfigured. + * + * @var float + * + * @Serializer\Expose() + * @Serializer\Groups({"Project_Entity"}) + * + * @ORM\Column(name="budget", type="float", nullable=false) + * @Assert\NotNull() + */ + private $budget = 0.00; + /** + * The time budget in seconds, will be be zero if unconfigured. + * + * @var int + * + * @Serializer\Expose() + * @Serializer\Groups({"Project_Entity"}) + * + * @ORM\Column(name="time_budget", type="integer", nullable=false) + * @Assert\NotNull() + */ + private $timeBudget = 0; + /** + * Meta fields + * + * All visible meta (custom) fields registered with this project + * * @var ProjectMeta[]|Collection * + * @Serializer\Expose() + * @Serializer\Groups({"Project"}) + * @Serializer\Type(name="array") + * @Serializer\SerializedName("metaFields") + * @Serializer\Accessor(getter="getVisibleMetaFields") + * * @ORM\OneToMany(targetEntity="App\Entity\ProjectMeta", mappedBy="project", cascade={"persist"}) */ private $meta; - /** + * Teams + * + * If no team is assigned, everyone can access the project (also depends on the teams of the customer) + * * @var Team[]|ArrayCollection * + * @Serializer\Expose() + * @Serializer\Groups({"Project"}) + * @SWG\Property(type="array", @SWG\Items(ref="#/definitions/Team")) + * * @ORM\ManyToMany(targetEntity="Team", cascade={"persist"}, inversedBy="projects") * @ORM\JoinTable( * name="kimai2_projects_teams", @@ -164,11 +265,7 @@ class Project implements EntityWithMetaFields return $this->name; } - /** - * @param string $comment - * @return Project - */ - public function setComment($comment): Project + public function setComment(?string $comment): Project { $this->comment = $comment; @@ -289,6 +386,30 @@ class Project implements EntityWithMetaFields return $this; } + public function setBudget(float $budget): Project + { + $this->budget = $budget; + + return $this; + } + + public function getBudget(): float + { + return $this->budget; + } + + public function setTimeBudget(int $seconds): Project + { + $this->timeBudget = $seconds; + + return $this; + } + + public function getTimeBudget(): int + { + return $this->timeBudget; + } + /** * @return Collection|MetaTableTypeInterface[] */ diff --git a/src/Entity/ProjectMeta.php b/src/Entity/ProjectMeta.php index a1806c55..fc6bf97c 100644 --- a/src/Entity/ProjectMeta.php +++ b/src/Entity/ProjectMeta.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace App\Entity; use Doctrine\ORM\Mapping as ORM; +use JMS\Serializer\Annotation as Serializer; use Symfony\Component\Validator\Constraints as Assert; /** @@ -21,6 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert; * @ORM\UniqueConstraint(columns={"project_id", "name"}) * } * ) + * @Serializer\ExclusionPolicy("all") */ class ProjectMeta implements MetaTableTypeInterface { diff --git a/src/Entity/ProjectRate.php b/src/Entity/ProjectRate.php index 485d37ad..05798d22 100644 --- a/src/Entity/ProjectRate.php +++ b/src/Entity/ProjectRate.php @@ -10,6 +10,7 @@ namespace App\Entity; use Doctrine\ORM\Mapping as ORM; +use JMS\Serializer\Annotation as Serializer; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Validator\Constraints as Assert; @@ -21,6 +22,8 @@ use Symfony\Component\Validator\Constraints as Assert; * ) * @ORM\Entity(repositoryClass="App\Repository\ProjectRateRepository") * @UniqueEntity({"user", "project"}, ignoreNull=false) + * + * @Serializer\ExclusionPolicy("all") */ class ProjectRate implements RateInterface { @@ -29,6 +32,8 @@ class ProjectRate implements RateInterface /** * @var Project * + * @Serializer\Exclude() + * * @ORM\ManyToOne(targetEntity="App\Entity\Project") * @ORM\JoinColumn(onDelete="CASCADE", nullable=false) * @Assert\NotNull diff --git a/src/Entity/Rate.php b/src/Entity/Rate.php index 27aa0fdd..270234e4 100644 --- a/src/Entity/Rate.php +++ b/src/Entity/Rate.php @@ -10,6 +10,7 @@ namespace App\Entity; use Doctrine\ORM\Mapping as ORM; +use JMS\Serializer\Annotation as Serializer; use Swagger\Annotations as SWG; use Symfony\Component\Validator\Constraints as Assert; @@ -18,6 +19,9 @@ trait Rate /** * @var int|null * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="id", type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="IDENTITY") @@ -26,14 +30,20 @@ trait Rate /** * @var User * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @SWG\Property(ref="#/definitions/User") + * * @ORM\ManyToOne(targetEntity="App\Entity\User") * @ORM\JoinColumn(onDelete="CASCADE", nullable=true) - * @SWG\Property(ref="#/definitions/User") */ private $user; /** * @var float * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="rate", type="float", nullable=false) * @Assert\GreaterThanOrEqual(0) */ @@ -41,12 +51,18 @@ trait Rate /** * @var float|null * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="internal_rate", type="float", nullable=true) */ private $internalRate; /** * @var bool * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="fixed", type="boolean", nullable=false) * @Assert\NotNull() */ diff --git a/src/Entity/Tag.php b/src/Entity/Tag.php index 2b94ba30..dec451d6 100644 --- a/src/Entity/Tag.php +++ b/src/Entity/Tag.php @@ -11,6 +11,7 @@ namespace App\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\Mapping as ORM; +use JMS\Serializer\Annotation as Serializer; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Validator\Constraints as Assert; @@ -22,21 +23,32 @@ use Symfony\Component\Validator\Constraints as Assert; * ) * @ORM\Entity(repositoryClass="App\Repository\TagRepository") * @UniqueEntity("name") + * + * @Serializer\ExclusionPolicy("all") */ class Tag { /** + * The internal ID + * * @var int * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="id", type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="IDENTITY") */ private $id; - /** + * The tag name + * * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="name", type="string", length=100, nullable=false) * @Assert\NotBlank() * @Assert\Length(min=2, max=100, allowEmptyString=false) @@ -49,9 +61,11 @@ class Tag /** * @var Timesheet[]|ArrayCollection * + * @Serializer\Exclude() + * * @ORM\ManyToMany(targetEntity="Timesheet", mappedBy="tags", fetch="EXTRA_LAZY") */ - protected $timesheets; + private $timesheets; public function __construct() { diff --git a/src/Entity/Team.php b/src/Entity/Team.php index f3b76710..a8fe8f88 100644 --- a/src/Entity/Team.php +++ b/src/Entity/Team.php @@ -12,6 +12,8 @@ namespace App\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use JMS\Serializer\Annotation as Serializer; +use Swagger\Annotations as SWG; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Validator\Constraints as Assert; @@ -23,48 +25,92 @@ use Symfony\Component\Validator\Constraints as Assert; * ) * @ORM\Entity(repositoryClass="App\Repository\TeamRepository") * @UniqueEntity("name") + * + * @Serializer\ExclusionPolicy("all") */ class Team { /** + * The internal ID + * * @var int * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="id", type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="IDENTITY") */ private $id; /** + * Team name + * * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="name", type="string", length=100, nullable=false) * @Assert\NotBlank() * @Assert\Length(min=2, max=100, allowEmptyString=false) */ private $name; /** + * Teamlead + * + * The teamlead for this team + * * @var User * + * @Serializer\Expose() + * @Serializer\Groups({"Team_Entity"}) + * @SWG\Property(ref="#/definitions/User") + * * @ORM\ManyToOne(targetEntity="App\Entity\User") * @ORM\JoinColumn(onDelete="CASCADE", nullable=false) * @Assert\NotNull() */ private $teamlead; /** + * Team member + * + * All team member, including the teamlead + * * @var User[]|ArrayCollection * + * @Serializer\Expose() + * @Serializer\Groups({"Team_Entity"}) + * @SWG\Property(type="array", @SWG\Items(ref="#/definitions/User")) + * * @ORM\ManyToMany(targetEntity="User", mappedBy="teams", fetch="EXTRA_LAZY") */ private $users; /** + * Customers + * + * All customers assigned to the team + * * @var Customer[]|ArrayCollection * + * @Serializer\Expose() + * @Serializer\Groups({"Team_Entity"}) + * @SWG\Property(type="array", @SWG\Items(ref="#/definitions/Customer")) + * * @ORM\ManyToMany(targetEntity="Customer", mappedBy="teams", fetch="EXTRA_LAZY") */ private $customers; /** + * Projects + * + * All projects assigned to the team + * * @var Project[]|ArrayCollection * + * @Serializer\Expose() + * @Serializer\Groups({"Team_Entity", "Expanded"}) + * @SWG\Property(type="array", @SWG\Items(ref="#/definitions/Project")) + * * @ORM\ManyToMany(targetEntity="Project", mappedBy="teams", fetch="EXTRA_LAZY") */ private $projects; diff --git a/src/Entity/Timesheet.php b/src/Entity/Timesheet.php index c31babe7..aba01286 100644 --- a/src/Entity/Timesheet.php +++ b/src/Entity/Timesheet.php @@ -10,12 +10,15 @@ namespace App\Entity; use App\Export\ExportItemInterface; +use App\Validator\Constraints as Constraints; use DateTime; use DateTimeZone; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Gedmo\Mapping\Annotation as Gedmo; +use JMS\Serializer\Annotation as Serializer; +use Swagger\Annotations as SWG; use Symfony\Component\Validator\Constraints as Assert; /** @@ -31,7 +34,45 @@ use Symfony\Component\Validator\Constraints as Assert; * ) * @ORM\Entity(repositoryClass="App\Repository\TimesheetRepository") * @ORM\HasLifecycleCallbacks() - * @App\Validator\Constraints\Timesheet + * @Constraints\Timesheet + * + * @Serializer\ExclusionPolicy("all") + * @Serializer\VirtualProperty( + * "ActivityAsId", + * exp="object.getActivity() === null ? null : object.getActivity().getId()", + * options={ + * @Serializer\SerializedName("activity"), + * @Serializer\Type(name="integer"), + * @Serializer\Groups({"Not_Expanded"}) + * } + * ) + * @Serializer\VirtualProperty( + * "ProjectAsId", + * exp="object.getProject() === null ? null : object.getProject().getId()", + * options={ + * @Serializer\SerializedName("project"), + * @Serializer\Type(name="integer"), + * @Serializer\Groups({"Not_Expanded"}) + * } + * ) + * @Serializer\VirtualProperty( + * "UserAsId", + * exp="object.getUser().getId()", + * options={ + * @Serializer\SerializedName("user"), + * @Serializer\Type(name="integer"), + * @Serializer\Groups({"Default"}) + * } + * ) + * @Serializer\VirtualProperty( + * "TagsAsArray", + * exp="object.getTagsAsArray()", + * options={ + * @Serializer\SerializedName("tags"), + * @Serializer\Type(name="array"), + * @Serializer\Groups({"Default"}) + * } + * ) */ class Timesheet implements EntityWithMetaFields, ExportItemInterface { @@ -59,47 +100,57 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface /** * @var int|null * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="id", type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="IDENTITY") */ private $id; - /** * @var DateTime * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="DateTime") + * * @ORM\Column(name="start_time", type="datetime", nullable=false) * @Assert\NotNull() */ private $begin; - /** * @var DateTime * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * @Serializer\Type(name="DateTime") + * * @ORM\Column(name="end_time", type="datetime", nullable=true) */ private $end; - /** * @var string + * @internal for storing the timezone of "begin" and "end" date * * @ORM\Column(name="timezone", type="string", length=64, nullable=false) */ private $timezone; - /** * @var bool + * @internal for storing the localized state of dates (see $timezone) */ private $localized = false; - /** * @var int * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="duration", type="integer", nullable=true) * @Assert\GreaterThanOrEqual(0) */ private $duration = 0; - /** * @var User * @@ -108,71 +159,92 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface * @Assert\NotNull() */ private $user; - /** + * Activity + * * @var Activity * + * @Serializer\Expose() + * @Serializer\Groups({"Expanded"}) + * @SWG\Property(type="array", @SWG\Items(ref="#/definitions/ActivityExpanded")) + * * @ORM\ManyToOne(targetEntity="App\Entity\Activity") * @ORM\JoinColumn(onDelete="CASCADE", nullable=false) * @Assert\NotNull() */ private $activity; - /** + * Project + * * @var Project * + * @Serializer\Expose() + * @Serializer\Groups({"Subresource", "Expanded"}) + * @SWG\Property(type="array", @SWG\Items(ref="#/definitions/ProjectExpanded")) + * * @ORM\ManyToOne(targetEntity="App\Entity\Project") * @ORM\JoinColumn(onDelete="CASCADE", nullable=false) * @Assert\NotNull() */ private $project; - /** * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="description", type="text", nullable=true) */ private $description; - /** * @var float * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="rate", type="float", nullable=false) * @Assert\GreaterThanOrEqual(0) */ private $rate = 0.00; - /** * @var float|null * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="internal_rate", type="float", nullable=true) */ private $internalRate; - /** * @var float|null * + * @Serializer\Expose() + * @Serializer\Groups({"Entity"}) + * * @ORM\Column(name="fixed_rate", type="float", nullable=true) * @Assert\GreaterThanOrEqual(0) */ private $fixedRate = null; - /** * @var float * + * @Serializer\Expose() + * @Serializer\Groups({"Entity"}) + * * @ORM\Column(name="hourly_rate", type="float", nullable=true) * @Assert\GreaterThanOrEqual(0) */ private $hourlyRate = null; - /** * @var bool * + * @Serializer\Expose() + * @Serializer\Groups({"Entity"}) + * * @ORM\Column(name="exported", type="boolean", nullable=false) * @Assert\NotNull() */ private $exported = false; - /** * @var bool * @@ -180,7 +252,6 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface * @Assert\NotNull() */ private $billable = true; - /** * @var string * @@ -188,16 +259,17 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface * @Assert\NotNull() */ private $category = self::WORK; - /** * @var DateTime|null + * @internal used for limiting queries, eg. via API sync * * @Gedmo\Timestampable * @ORM\Column(name="modified_at", type="datetime", nullable=true) */ private $modifiedAt; - /** + * Tags + * * @var Tag[]|ArrayCollection * * @ORM\ManyToMany(targetEntity="App\Entity\Tag", inversedBy="timesheets", cascade={"persist"}) @@ -213,10 +285,19 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface * @Assert\Valid() */ private $tags; - /** + * Meta fields + * + * All visible meta (custom) fields registered with this timesheet + * * @var TimesheetMeta[]|Collection * + * @Serializer\Expose() + * @Serializer\Groups({"Timesheet"}) + * @Serializer\Type(name="array") + * @Serializer\SerializedName("metaFields") + * @Serializer\Accessor(getter="getVisibleMetaFields") + * * @ORM\OneToMany(targetEntity="App\Entity\TimesheetMeta", mappedBy="timesheet", cascade={"persist"}) */ private $meta; diff --git a/src/Entity/TimesheetMeta.php b/src/Entity/TimesheetMeta.php index bd0e84ac..e31e0991 100644 --- a/src/Entity/TimesheetMeta.php +++ b/src/Entity/TimesheetMeta.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace App\Entity; use Doctrine\ORM\Mapping as ORM; +use JMS\Serializer\Annotation as Serializer; use Symfony\Component\Validator\Constraints as Assert; /** @@ -21,6 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert; * @ORM\UniqueConstraint(columns={"timesheet_id", "name"}) * } * ) + * @Serializer\ExclusionPolicy("all") */ class TimesheetMeta implements MetaTableTypeInterface { diff --git a/src/Entity/User.php b/src/Entity/User.php index 32002a47..72fb2674 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -14,6 +14,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use FOS\UserBundle\Model\User as BaseUser; +use JMS\Serializer\Annotation as Serializer; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Validator\Constraints as Assert; @@ -28,6 +29,26 @@ use Symfony\Component\Validator\Constraints as Assert; * ) * @UniqueEntity("username") * @UniqueEntity("email") + * + * @Serializer\ExclusionPolicy("all") + * @Serializer\VirtualProperty( + * "LanguageAsString", + * exp="object.getLocale()", + * options={ + * @Serializer\SerializedName("language"), + * @Serializer\Type(name="string"), + * @Serializer\Groups({"User_Entity"}) + * } + * ) + * @Serializer\VirtualProperty( + * "TimezoneAsString", + * exp="object.getTimezone()", + * options={ + * @Serializer\SerializedName("timezone"), + * @Serializer\Type(name="string"), + * @Serializer\Groups({"User_Entity"}) + * } + * ) */ class User extends BaseUser implements UserInterface { @@ -44,67 +65,94 @@ class User extends BaseUser implements UserInterface public const AUTH_SAML = 'saml'; /** + * Internal ID + * * @var int + * @internal must be protected because of parent class + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) * * @ORM\Id * @ORM\GeneratedValue * @ORM\Column(name="id", type="integer") */ protected $id; - /** + * The user alias will be displayed in the frontend instead of the username + * * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * * @ORM\Column(name="alias", type="string", length=60, nullable=true) * @Assert\Length(max=60) */ private $alias; - /** + * Registration date for the user + * * @var \DateTime * * @ORM\Column(name="registration_date", type="datetime", nullable=true) */ private $registeredAt; - /** + * An additional title for the user, like the Job position or Department + * * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"User_Entity"}) + * * @ORM\Column(name="title", type="string", length=50, nullable=true) * @Assert\Length(max=50) */ private $title; - /** + * URL to the users avatar, will be auto-generated if empty + * * @var string * + * @Serializer\Expose() + * @Serializer\Groups({"User_Entity"}) + * * @ORM\Column(name="avatar", type="string", length=255, nullable=true) * @Assert\Length(max=255) */ private $avatar; - /** + * API token (password) for this user + * * @var string * * @ORM\Column(name="api_token", type="string", length=255, nullable=true) */ - protected $apiToken; - + private $apiToken; /** * @var string + * @internal to be set via form, must not be persisted */ - protected $plainApiToken; - + private $plainApiToken; /** + * User preferences + * + * List of preferences for this user, required ones have dedicated fields/methods + * * @var UserPreference[]|Collection * * @ORM\OneToMany(targetEntity="App\Entity\UserPreference", mappedBy="user", cascade={"persist"}) */ private $preferences; - /** + * All teams of the user + * * @var Team[]|ArrayCollection * + * @Serializer\Expose() + * @Serializer\Groups({"User_Entity"}) + * * @ORM\ManyToMany(targetEntity="Team", inversedBy="users", cascade={"persist"}) * @ORM\JoinTable( * name="kimai2_users_teams", @@ -117,25 +165,24 @@ class User extends BaseUser implements UserInterface * ) */ private $teams; - /** + * The type of authentication used by the user (eg. "kimai", "ldap", "saml") + * * @var string + * @internal for internal usage only * * @ORM\Column(name="auth", type="string", length=20, nullable=true) * @Assert\Length(max=20) */ private $auth = self::AUTH_INTERNAL; - /** * This flag will be initialized in UserEnvironmentSubscriber. * * @var bool|null + * @internal has no database mapping. as the value is calculated from a permission */ private $isAllowedToSeeAllData = null; - /** - * User constructor. - */ public function __construct() { parent::__construct(); diff --git a/src/Form/AbstractRateForm.php b/src/Form/AbstractRateForm.php index 81b53e83..5413a476 100644 --- a/src/Form/AbstractRateForm.php +++ b/src/Form/AbstractRateForm.php @@ -27,7 +27,7 @@ abstract class AbstractRateForm extends AbstractType // documentation is for NelmioApiDocBundle 'documentation' => [ 'type' => 'number', - 'description' => 'Rate', + 'description' => 'The rate (eg. 10.5)', ], 'label' => 'label.rate', 'attr' => [ @@ -40,7 +40,7 @@ abstract class AbstractRateForm extends AbstractType // documentation is for NelmioApiDocBundle 'documentation' => [ 'type' => 'number', - 'description' => 'Internal rate', + 'description' => 'The internal rate (eg. 10.0 or 10)', ], 'label' => 'label.rate_internal', 'currency' => $currency, @@ -50,6 +50,10 @@ abstract class AbstractRateForm extends AbstractType ->add('isFixed', YesNoType::class, [ 'label' => 'label.fixedRate', 'help' => 'help.fixedRate', + 'documentation' => [ + 'type' => 'boolean', + 'description' => 'If "true" each time record gets the same rate, regardless of its duration', + ], ]) ; } diff --git a/src/Form/TagEditForm.php b/src/Form/TagEditForm.php index d026b118..6b58bfd4 100644 --- a/src/Form/TagEditForm.php +++ b/src/Form/TagEditForm.php @@ -29,6 +29,10 @@ class TagEditForm extends AbstractType 'attr' => [ 'autofocus' => 'autofocus' ], + 'documentation' => [ + 'type' => 'string', + 'description' => 'The tag name (forbidden character: comma)', + ], ]) ->add('color', ColorPickerType::class); } diff --git a/src/Form/TeamEditForm.php b/src/Form/TeamEditForm.php index 599e1ce1..476fe968 100644 --- a/src/Form/TeamEditForm.php +++ b/src/Form/TeamEditForm.php @@ -32,7 +32,7 @@ class TeamEditForm extends AbstractType // documentation is for NelmioApiDocBundle 'documentation' => [ 'type' => 'string', - 'description' => 'Name of the new team', + 'description' => 'Name of the team', ], ]) ->add('teamlead', UserType::class, [ @@ -49,6 +49,12 @@ class TeamEditForm extends AbstractType 'multiple' => true, 'expanded' => $options['expand_users'], 'by_reference' => false, + 'documentation' => [ + 'type' => 'array', + 'items' => ['type' => 'integer', 'description' => 'User IDs'], + 'title' => 'Team member', + 'description' => 'Array of team member IDs', + ], ]) ; } diff --git a/src/Form/Type/ColorPickerType.php b/src/Form/Type/ColorPickerType.php index 4a89e090..57296f84 100644 --- a/src/Form/Type/ColorPickerType.php +++ b/src/Form/Type/ColorPickerType.php @@ -33,6 +33,10 @@ class ColorPickerType extends AbstractType implements DataTransformerInterface public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ + 'documentation' => [ + 'type' => 'string', + 'description' => sprintf('The color code as hex (default: %s)', self::DEFAULT_COLOR), + ], 'label' => 'label.color', ]); } diff --git a/src/Form/Type/UserType.php b/src/Form/Type/UserType.php index 0d3e1f6e..bc1c3977 100644 --- a/src/Form/Type/UserType.php +++ b/src/Form/Type/UserType.php @@ -34,6 +34,10 @@ class UserType extends AbstractType return $user->getDisplayName(); }, 'choice_translation_domain' => false, + 'documentation' => [ + 'type' => 'integer', + 'description' => 'User ID', + ], ]); $resolver->setDefault('query_builder', function (Options $options) { diff --git a/src/Form/UserCreateType.php b/src/Form/UserCreateType.php index f37b5c8d..4cd1f23f 100644 --- a/src/Form/UserCreateType.php +++ b/src/Form/UserCreateType.php @@ -56,6 +56,7 @@ class UserCreateType extends UserEditType parent::configureOptions($resolver); $resolver->setDefaults([ + 'validation_groups' => ['UserCreate'], 'include_add_more' => false, ]); } diff --git a/src/Form/UserEditType.php b/src/Form/UserEditType.php index 75385bee..24959e3f 100644 --- a/src/Form/UserEditType.php +++ b/src/Form/UserEditType.php @@ -72,6 +72,7 @@ class UserEditType extends AbstractType public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ + 'validation_groups' => ['Profile'], 'data_class' => User::class, 'csrf_protection' => true, 'csrf_field_name' => '_token', diff --git a/src/Form/UserPasswordType.php b/src/Form/UserPasswordType.php index ca9245d1..30699b2c 100644 --- a/src/Form/UserPasswordType.php +++ b/src/Form/UserPasswordType.php @@ -41,6 +41,7 @@ class UserPasswordType extends AbstractType public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ + 'validation_groups' => ['PasswordUpdate'], 'data_class' => User::class, 'csrf_protection' => true, 'csrf_field_name' => '_token', diff --git a/src/Form/UserRolesType.php b/src/Form/UserRolesType.php index 442708b7..4c849124 100644 --- a/src/Form/UserRolesType.php +++ b/src/Form/UserRolesType.php @@ -40,6 +40,7 @@ class UserRolesType extends AbstractType public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ + 'validation_groups' => ['RolesUpdate'], 'data_class' => User::class, 'csrf_protection' => true, 'csrf_field_name' => '_token', diff --git a/tests/API/APIControllerBaseTest.php b/tests/API/APIControllerBaseTest.php index c5ab7983..49072821 100644 --- a/tests/API/APIControllerBaseTest.php +++ b/tests/API/APIControllerBaseTest.php @@ -12,6 +12,7 @@ namespace App\Tests\API; use App\DataFixtures\UserFixtures; use App\Entity\User; use App\Tests\Controller\ControllerBaseTest; +use PHPUnit\Framework\Constraint\IsType; use Symfony\Component\DomCrawler\Crawler; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\HttpKernelBrowser; @@ -240,7 +241,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest /** * @param Response $response * @param string[] $failedFields - * @param bool $extraFields + * @param bool $extraFields test for the error "This form should not contain extra fields" */ protected function assertApiCallValidationError(Response $response, array $failedFields, bool $extraFields = false) { @@ -258,8 +259,366 @@ abstract class APIControllerBaseTest extends ControllerBaseTest $data = $result['errors']['children']; foreach ($failedFields as $fieldName) { - self::assertArrayHasKey($fieldName, $data); - self::assertArrayHasKey('errors', $data[$fieldName]); + self::assertArrayHasKey($fieldName, $data, sprintf('Could not find validation error for field: %s', $fieldName)); + self::assertArrayHasKey('errors', $data[$fieldName], sprintf('Field %s has no validation problem', $fieldName)); + } + + $foundErrors = []; + foreach ($data as $fieldName => $field) { + if (\array_key_exists('errors', $field) && \count($field['errors']) > 0) { + $foundErrors[$fieldName] = \count($field['errors']); + } + } + + self::assertEquals(\count($failedFields), \count($foundErrors), 'Expected and actual validation error amount differs'); + } + + protected static function getExpectedResponseStructure(string $type): array + { + switch ($type) { + case 'TagEntity': + return [ + 'id' => 'int', + 'name' => 'string', + 'color' => 'string', + ]; + + // embedded meta data + case 'CustomerMeta': + case 'ProjectMeta': + case 'ActivityMeta': + case 'TimesheetMeta': + return [ + 'name' => 'string', + 'value' => 'string', + ]; + + // if a user is embedded in other objects + case 'User': + // if a list of users is loaded + case 'UserCollection': + return [ + 'id' => 'int', + 'username' => 'string', + 'enabled' => 'bool', + 'alias' => '@string', + ]; + + // if a user is loaded explicitly + case 'UserEntity': + return [ + 'id' => 'int', + 'username' => 'string', + 'enabled' => 'bool', + 'alias' => '@string', + 'title' => '@string', + 'avatar' => '@string', + 'teams' => ['result' => 'array', 'type' => 'Team'], + 'roles' => ['result' => 'array', 'type' => 'string'], + 'language' => 'string', + 'timezone' => 'string', + ]; + + // if a team is embedded + case 'Team': + // if a collection of teams is requested + case 'TeamCollection': + return [ + 'id' => 'int', + 'name' => 'string', + ]; + + // explicitly requested team + case 'TeamEntity': + return [ + 'id' => 'int', + 'name' => 'string', + 'teamlead' => ['result' => 'object', 'type' => 'User'], + 'users' => ['result' => 'array', 'type' => 'User'], + 'customers' => ['result' => 'array', 'type' => '@Customer'], + 'projects' => ['result' => 'array', 'type' => '@Project'], + ]; + + // if a customer is embedded in other objects + case 'Customer': + return [ + 'id' => 'int', + 'name' => 'string', + 'visible' => 'bool', + 'color' => '@string', + ]; + + // if a list of customers is loaded + case 'CustomerCollection': + return [ + 'id' => 'int', + 'name' => 'string', + 'visible' => 'boolean', + 'color' => '@string', + 'metaFields' => ['result' => 'array', 'type' => 'CustomerMeta'], + 'teams' => ['result' => 'array', 'type' => 'Team'], + 'currency' => 'string', // since 1.10 + ]; + + // if a customer is loaded explicitly + case 'CustomerEntity': + return [ + 'id' => 'int', + 'name' => 'string', + 'visible' => 'bool', + 'color' => '@string', + 'metaFields' => ['result' => 'array', 'type' => 'CustomerMeta'], + 'teams' => ['result' => 'array', 'type' => 'Team'], + 'homepage' => '@string', + 'number' => '@string', + 'comment' => '@string', + 'company' => '@string', + 'contact' => '@string', + 'address' => '@string', + 'country' => 'string', + 'currency' => 'string', + 'phone' => '@string', + 'fax' => '@string', + 'mobile' => '@string', + 'email' => '@string', + 'timezone' => 'string', + 'budget' => 'float', + 'timeBudget' => 'int', + 'vatId' => '@string', // since 1.10 + ]; + + // if a project is embedded + case 'Project': + return [ + 'id' => 'int', + 'name' => 'string', + 'visible' => 'bool', + 'color' => '@string', + 'customer' => 'int', + ]; + + // if a project is embedded in an expanded collection (here timesheet) + case 'ProjectExpanded': + return [ + 'id' => 'int', + 'name' => 'string', + 'visible' => 'bool', + 'color' => '@string', + 'customer' => ['result' => 'object', 'type' => 'Customer'], + ]; + + // if a collection of projects is loaded + case 'ProjectCollection': + return [ + 'id' => 'int', + 'name' => 'string', + 'visible' => 'bool', + 'customer' => 'int', + 'color' => '@string', + 'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], + 'parentTitle' => 'string', + 'start' => '@datetime', + 'end' => '@datetime', + 'teams' => ['result' => 'array', 'type' => 'Team'], + ]; + + // if a project is explicitly loaded + case 'ProjectEntity': + return [ + 'id' => 'int', + 'name' => 'string', + 'visible' => 'bool', + 'customer' => 'int', + 'color' => '@string', + 'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], + 'parentTitle' => 'string', + 'start' => '@datetime', + 'end' => '@datetime', + 'teams' => ['result' => 'array', 'type' => 'Team'], + 'comment' => '@string', + 'budget' => 'float', + 'timeBudget' => 'int', + 'orderNumber' => '@string', + 'orderDate' => '@datetime', + ]; + + // embedded activities + case 'Activity': + return [ + 'id' => 'int', + 'name' => 'string', + 'visible' => 'bool', + 'project' => '@int', + 'color' => '@string', + ]; + + // collection of activities + case 'ActivityCollection': + return [ + 'id' => 'int', + 'name' => 'string', + 'visible' => 'bool', + 'project' => '@int', + 'color' => '@string', + 'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], + 'parentTitle' => '@string', + ]; + + // if a activity is explicitly loaded + case 'ActivityEntity': + return [ + 'id' => 'int', + 'name' => 'string', + 'visible' => 'bool', + 'project' => '@int', + 'color' => '@string', + 'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], + 'parentTitle' => '@string', + 'comment' => '@string', + 'budget' => 'float', + 'timeBudget' => 'int', + ]; + + case 'TimesheetEntity': + return [ + 'id' => 'int', + 'begin' => 'DateTime', + 'end' => '@DateTime', + 'duration' => '@int', + 'description' => '@string', + 'rate' => 'float', + 'activity' => 'int', + 'project' => 'int', + 'tags' => ['result' => 'array', 'type' => 'string'], + 'user' => 'int', + 'metaFields' => ['result' => 'array', 'type' => 'TimesheetMeta'], + 'internalRate' => 'float', + 'exported' => 'bool', + 'fixedRate' => '@float', + 'hourlyRate' => '@float', + // TODO new fields: billable, category + ]; + + case 'TimesheetCollection': + return [ + 'id' => 'int', + 'begin' => 'DateTime', + 'end' => '@DateTime', + 'duration' => '@int', + 'description' => '@string', + 'rate' => 'float', + 'activity' => 'int', + 'project' => 'int', + 'tags' => ['result' => 'array', 'type' => 'string'], + 'user' => 'int', + 'metaFields' => ['result' => 'array', 'type' => 'TimesheetMeta'], + 'internalRate' => 'float', + ]; + + case 'TimesheetCollectionFull': + return [ + 'id' => 'int', + 'begin' => 'DateTime', + 'end' => '@DateTime', + 'duration' => '@int', + 'description' => '@string', + 'rate' => 'float', + 'activity' => ['result' => 'object', 'type' => 'Activity'], + 'project' => ['result' => 'object', 'type' => 'ProjectExpanded'], + 'tags' => ['result' => 'array', 'type' => 'string'], + 'user' => 'int', + 'metaFields' => ['result' => 'array', 'type' => 'TimesheetMeta'], + 'internalRate' => 'float', + ]; + + default: + throw new \Exception(sprintf('Unknown API response type: %s', $type)); + } + } + + /** + * The $type is either one of the types configured in config/packages/nelmio_api_doc.yaml or the class name. + * + * @param string $type + * @param array $result + * @throws \Exception + */ + protected function assertApiResponseTypeStructure(string $type, array $result) + { + $expected = self::getExpectedResponseStructure($type); + $expectedKeys = array_keys($expected); + + $actual = array_keys($result); + sort($actual); + sort($expectedKeys); + + self::assertEquals($expectedKeys, $actual, sprintf('Structure for API response type "%s" does not match', $type)); + + self::assertEquals( + \count($actual), + \count($expectedKeys), + sprintf('Mismatch between expected and result keys for API response type "%s". Expected %s keys but found %s.', $type, \count($expected), \count($actual)) + ); + + foreach ($expected as $key => $value) { + if (\is_array($value)) { + switch ($value['result']) { + case 'array': + foreach ($result[$key] as $subResult) { + if ($value['type'] === 'string') { + self::assertIsString($subResult); + } else { + self::assertIsArray($subResult); + + if ($value['type'][0] === '@') { + if (empty($result[$key])) { + continue; + } + $value['type'] = substr($value['type'], 1); + } + + self::assertApiResponseTypeStructure($value['type'], $subResult); + } + } + break; + + case 'object': + self::assertIsArray($result[$key], sprintf('Key "%s" in type "%s" is not an array', $key, $type)); + + if ($value['type'][0] === '@') { + if (empty($result[$key])) { + break; + } + $value['type'] = substr($value['type'], 1); + } + + self::assertApiResponseTypeStructure($value['type'], $result[$key]); + break; + + default: + throw new \Exception(sprintf('Invalid result type "%s" for subresource given', $value['result'])); + } + + continue; + } + + if ($value[0] === '@') { + if (\is_null($result[$key])) { + continue; + } + $value = substr($value, 1); + } + + if (strtolower($value) === 'datetime') { + // TODO + $value = 'string'; + } + + static::assertThat( + $result[$key], + new IsType($value), + sprintf('Found type mismatch in structure for API response type %s. Expected type "%s" for key "%s".', $type, $value, $key) + ); } } } diff --git a/tests/API/ActivityControllerTest.php b/tests/API/ActivityControllerTest.php index 08e19f3d..e07a6e85 100644 --- a/tests/API/ActivityControllerTest.php +++ b/tests/API/ActivityControllerTest.php @@ -11,6 +11,7 @@ namespace App\Tests\API; use App\DataFixtures\UserFixtures; use App\Entity\Activity; +use App\Entity\ActivityMeta; use App\Entity\ActivityRate; use App\Entity\Customer; use App\Entity\Project; @@ -103,6 +104,12 @@ class ActivityControllerTest extends APIControllerBaseTest $em->persist($activity); $activity = (new Activity())->setName('fifth one')->setComment('5')->setProject($project2); + $meta = new ActivityMeta(); + $meta->setName('bar')->setValue('foo')->setIsVisible(false); + $activity->setMetaField($meta); + $meta = new ActivityMeta(); + $meta->setName('foo')->setValue('bar')->setIsVisible(true); + $activity->setMetaField($meta); $em->persist($activity); $activity = (new Activity())->setName('sixth one')->setComment('6')->setVisible(false); @@ -127,7 +134,7 @@ class ActivityControllerTest extends APIControllerBaseTest for ($i = 0; $i < \count($result); $i++) { $activity = $result[$i]; $hasProject = $expected[$i][0]; - $this->assertStructure($activity, false); + self::assertApiResponseTypeStructure('ActivityCollection', $activity); if ($hasProject) { $this->assertEquals($expected[$i][1], $activity['project']); } @@ -161,7 +168,7 @@ class ActivityControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertEquals(5, \count($result)); - $this->assertStructure($result[0], false); + self::assertApiResponseTypeStructure('ActivityCollection', $result[0]); $this->assertEquals(1, $result[4]['project']); $this->assertEquals(2, $result[3]['project']); $this->assertEquals(2, $result[2]['project']); @@ -174,7 +181,7 @@ class ActivityControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result, true); + self::assertApiResponseTypeStructure('ActivityEntity', $result); } public function testNotFound() @@ -197,7 +204,7 @@ class ActivityControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('ActivityEntity', $result); $this->assertNotEmpty($result['id']); } @@ -212,7 +219,7 @@ class ActivityControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('ActivityEntity', $result); $this->assertNotEmpty($result['id']); } @@ -262,7 +269,7 @@ class ActivityControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('ActivityEntity', $result); $this->assertNotEmpty($result['id']); } @@ -351,23 +358,4 @@ class ActivityControllerTest extends APIControllerBaseTest $activity = $em->getRepository(Activity::class)->find(1); $this->assertEquals('another,testing,bar', $activity->getMetaField('metatestmock')->getValue()); } - - protected function assertStructure(array $result, $full = true) - { - $expectedKeys = [ - 'id', 'name', 'visible', 'project', 'color', 'metaFields', 'parentTitle' - ]; - - if ($full) { - $expectedKeys = array_merge($expectedKeys, [ - 'comment', 'budget', 'timeBudget' - ]); - } - - $actual = array_keys($result); - sort($actual); - sort($expectedKeys); - - $this->assertEquals($expectedKeys, $actual, 'Activity structure does not match'); - } } diff --git a/tests/API/ApiDocControllerTest.php b/tests/API/ApiDocControllerTest.php index 755e8583..eafe4a0b 100644 --- a/tests/API/ApiDocControllerTest.php +++ b/tests/API/ApiDocControllerTest.php @@ -27,6 +27,24 @@ class ApiDocControllerTest extends ControllerBaseTest $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $this->assertAccessIsGranted($client, '/api/doc'); $this->assertStringContainsString('Kimai 2 - API Docs', $client->getResponse()->getContent()); + $result = $client->getCrawler()->filter('script#swagger-data'); + $swaggerJson = json_decode($result->text(), true); + $tags = []; + foreach ($swaggerJson['spec']['paths'] as $path) { + foreach ($path as $method) { + foreach ($method['tags'] as $tag) { + $tags[$tag] = $tag; + } + } + } + + $expectedKeys = ['Activity', 'Default', 'Customer', 'Project', 'Tag', 'Team', 'Timesheet', 'User']; + $actual = array_keys($tags); + + sort($actual); + sort($expectedKeys); + + self::assertEquals($expectedKeys, $actual, sprintf('Expected %s sections in API docs, but found %s.', \count($actual), \count($expectedKeys))); } public function testGetJsonDocs() diff --git a/tests/API/CustomerControllerTest.php b/tests/API/CustomerControllerTest.php index 82379062..292891b4 100644 --- a/tests/API/CustomerControllerTest.php +++ b/tests/API/CustomerControllerTest.php @@ -10,8 +10,12 @@ namespace App\Tests\API; use App\DataFixtures\UserFixtures; +use App\Entity\Activity; use App\Entity\Customer; +use App\Entity\CustomerMeta; use App\Entity\CustomerRate; +use App\Entity\Project; +use App\Entity\Team; use App\Entity\User; use App\Repository\CustomerRateRepository; use App\Repository\CustomerRepository; @@ -84,12 +88,12 @@ class CustomerControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertEquals(1, \count($result)); - $this->assertStructure($result[0], false); + self::assertApiResponseTypeStructure('CustomerCollection', $result[0]); } public function testGetCollectionWithQuery() { - $query = ['order' => 'ASC', 'orderBy' => 'name', 'visible' => 3]; + $query = ['order' => 'ASC', 'orderBy' => 'name', 'visible' => 3, 'term' => 'test']; $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $this->assertAccessIsGranted($client, '/api/customers', 'GET', $query); $result = json_decode($client->getResponse()->getContent(), true); @@ -97,7 +101,7 @@ class CustomerControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertEquals(1, \count($result)); - $this->assertStructure($result[0], false); + self::assertApiResponseTypeStructure('CustomerCollection', $result[0]); } public function testGetEntity() @@ -107,7 +111,51 @@ class CustomerControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result, true); + self::assertApiResponseTypeStructure('CustomerEntity', $result); + } + + public function testGetEntityWithFullResponse() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); + + $em = $this->getEntityManager(); + + /** @var Customer $customer */ + $customer = $em->getRepository(Customer::class)->find(1); + + // add meta fields + $meta = new CustomerMeta(); + $meta->setName('bar')->setValue('foo')->setIsVisible(false); + $customer->setMetaField($meta); + $meta = new CustomerMeta(); + $meta->setName('foo')->setValue('bar')->setIsVisible(true); + $customer->setMetaField($meta); + $em->persist($customer); + + // add a new project ... + $project = new Project(); + $project->setName('Activity Test'); + $project->setCustomer($customer); + $em->persist($project); + + // ... with activity + $activity = (new Activity())->setName('first one')->setComment('1')->setProject($project); + $em->persist($activity); + + // and finally a team + $team = new Team(); + $team->setName('Testing customer 1 team'); + $team->setTeamLead($this->getUserByRole(User::ROLE_USER)); + $team->addCustomer($customer); + $team->addProject($project); + $team->addUser($this->getUserByRole(User::ROLE_TEAMLEAD)); + $em->persist($team); + + $this->assertAccessIsGranted($client, '/api/customers/1'); + $result = json_decode($client->getResponse()->getContent(), true); + + $this->assertIsArray($result); + self::assertApiResponseTypeStructure('CustomerEntity', $result); } public function testNotFound() @@ -132,7 +180,7 @@ class CustomerControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('CustomerEntity', $result); $this->assertNotEmpty($result['id']); } @@ -150,7 +198,7 @@ class CustomerControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('CustomerEntity', $result); $this->assertNotEmpty($result['id']); } @@ -206,7 +254,7 @@ class CustomerControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('CustomerEntity', $result); $this->assertNotEmpty($result['id']); } @@ -252,6 +300,13 @@ class CustomerControllerTest extends APIControllerBaseTest $this->assertApiCallValidationError($response, ['currency']); } + public function testMetaActionNotAllowed() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); + $this->request($client, '/api/customers/1/meta', 'PATCH', [], json_encode(['name' => 'asdasd'])); + $this->assertApiResponseAccessDenied($client->getResponse(), 'You are not allowed to update this customer'); + } + public function testMetaActionThrowsNotFound() { $this->assertEntityNotFoundForPatch(User::ROLE_ADMIN, '/api/customers/42/meta', []); @@ -299,24 +354,4 @@ class CustomerControllerTest extends APIControllerBaseTest $customer = $em->getRepository(Customer::class)->find(1); $this->assertEquals('another,testing,bar', $customer->getMetaField('metatestmock')->getValue()); } - - protected function assertStructure(array $result, $full = true) - { - $expectedKeys = [ - 'id', 'name', 'visible', 'color', 'metaFields', 'teams' - ]; - - if ($full) { - $expectedKeys = array_merge($expectedKeys, [ - 'homepage', 'number', 'comment', 'company', 'contact', 'address', 'country', 'currency', - 'phone', 'fax', 'mobile', 'email', 'timezone', 'budget', 'timeBudget' - ]); - } - - $actual = array_keys($result); - sort($actual); - sort($expectedKeys); - - $this->assertEquals($expectedKeys, $actual, 'Customer structure does not match'); - } } diff --git a/tests/API/Model/I18nConfigTest.php b/tests/API/Model/I18nConfigTest.php new file mode 100644 index 00000000..db9a0c8a --- /dev/null +++ b/tests/API/Model/I18nConfigTest.php @@ -0,0 +1,32 @@ +assertInstanceOf(I18nConfig::class, $sut->setIs24hours(false)); + $this->assertInstanceOf(I18nConfig::class, $sut->setDuration('foo')); + $this->assertInstanceOf(I18nConfig::class, $sut->setDate('bar')); + $this->assertInstanceOf(I18nConfig::class, $sut->setDateTime('hello')); + $this->assertInstanceOf(I18nConfig::class, $sut->setFormDate('world')); + $this->assertInstanceOf(I18nConfig::class, $sut->setFormDateTime('testing')); + $this->assertInstanceOf(I18nConfig::class, $sut->setTime('fun')); + } +} diff --git a/tests/API/Model/I18nTest.php b/tests/API/Model/I18nTest.php deleted file mode 100644 index 5ff52e58..00000000 --- a/tests/API/Model/I18nTest.php +++ /dev/null @@ -1,52 +0,0 @@ -assertTrue($sut->isIs24hours()); - $this->assertEquals('', $sut->getDuration()); - $this->assertEquals('', $sut->getDate()); - $this->assertEquals('', $sut->getDateTime()); - $this->assertEquals('', $sut->getFormDate()); - $this->assertEquals('', $sut->getFormDateTime()); - $this->assertEquals('', $sut->getTime()); - } - - public function testSetter() - { - $sut = new I18n(); - - $this->assertInstanceOf(I18n::class, $sut->setIs24hours(false)); - $this->assertInstanceOf(I18n::class, $sut->setDuration('foo')); - $this->assertInstanceOf(I18n::class, $sut->setDate('bar')); - $this->assertInstanceOf(I18n::class, $sut->setDateTime('hello')); - $this->assertInstanceOf(I18n::class, $sut->setFormDate('world')); - $this->assertInstanceOf(I18n::class, $sut->setFormDateTime('testing')); - $this->assertInstanceOf(I18n::class, $sut->setTime('fun')); - - $this->assertFalse($sut->isIs24hours()); - $this->assertEquals('foo', $sut->getDuration()); - $this->assertEquals('bar', $sut->getDate()); - $this->assertEquals('hello', $sut->getDateTime()); - $this->assertEquals('world', $sut->getFormDate()); - $this->assertEquals('testing', $sut->getFormDateTime()); - $this->assertEquals('fun', $sut->getTime()); - } -} diff --git a/tests/API/Model/TimesheetConfigTest.php b/tests/API/Model/TimesheetConfigTest.php index 9ae0e162..bc4090c1 100644 --- a/tests/API/Model/TimesheetConfigTest.php +++ b/tests/API/Model/TimesheetConfigTest.php @@ -17,17 +17,6 @@ use PHPUnit\Framework\TestCase; */ class TimesheetConfigTest extends TestCase { - public function testDefaultValues() - { - $sut = new TimesheetConfig(); - $this->assertTrue($sut->isAllowFutureTimes()); - $this->assertTrue($sut->isAllowOverlapping()); - $this->assertEquals('now', $sut->getDefaultBeginTime()); - $this->assertEquals('default', $sut->getTrackingMode()); - $this->assertEquals(1, $sut->getActiveEntriesSoftLimit()); - $this->assertEquals(1, $sut->getActiveEntriesHardLimit()); - } - public function testSetter() { $sut = new TimesheetConfig(); @@ -38,12 +27,5 @@ class TimesheetConfigTest extends TestCase $this->assertInstanceOf(TimesheetConfig::class, $sut->setTrackingMode('punch')); $this->assertInstanceOf(TimesheetConfig::class, $sut->setActiveEntriesSoftLimit(2)); $this->assertInstanceOf(TimesheetConfig::class, $sut->setActiveEntriesHardLimit(3)); - - $this->assertFalse($sut->isAllowFutureTimes()); - $this->assertFalse($sut->isAllowOverlapping()); - $this->assertEquals('08:00', $sut->getDefaultBeginTime()); - $this->assertEquals('punch', $sut->getTrackingMode()); - $this->assertEquals(2, $sut->getActiveEntriesSoftLimit()); - $this->assertEquals(3, $sut->getActiveEntriesHardLimit()); } } diff --git a/tests/API/ProjectControllerTest.php b/tests/API/ProjectControllerTest.php index 620cec03..8ad95ab2 100644 --- a/tests/API/ProjectControllerTest.php +++ b/tests/API/ProjectControllerTest.php @@ -12,7 +12,9 @@ namespace App\Tests\API; use App\DataFixtures\UserFixtures; use App\Entity\Customer; use App\Entity\Project; +use App\Entity\ProjectMeta; use App\Entity\ProjectRate; +use App\Entity\Team; use App\Entity\User; use App\Repository\ProjectRateRepository; use App\Repository\ProjectRepository; @@ -86,7 +88,7 @@ class ProjectControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertEquals(1, \count($result)); - $this->assertStructure($result[0], false); + self::assertApiResponseTypeStructure('ProjectCollection', $result[0]); } protected function loadProjectTestData(HttpKernelBrowser $client) @@ -114,8 +116,25 @@ class ProjectControllerTest extends APIControllerBaseTest $em->persist($project); $project = (new Project())->setName('fifth')->setVisible(true)->setCustomer($customer); + + // add meta fields + $meta = new ProjectMeta(); + $meta->setName('bar')->setValue('foo')->setIsVisible(false); + $project->setMetaField($meta); + $meta = new ProjectMeta(); + $meta->setName('foo')->setValue('bar')->setIsVisible(true); + $project->setMetaField($meta); $em->persist($project); + // and a team + $team = new Team(); + $team->setName('Testing project team'); + $team->setTeamLead($this->getUserByRole(User::ROLE_USER)); + $team->addCustomer($customer); + $team->addProject($project); + $team->addUser($this->getUserByRole(User::ROLE_TEAMLEAD)); + $em->persist($team); + $project = (new Project())->setName('sixth')->setVisible(false)->setCustomer($customer3); $em->persist($project); @@ -138,7 +157,7 @@ class ProjectControllerTest extends APIControllerBaseTest for ($i = 0; $i < \count($expected); $i++) { $project = $result[$i]; $compare = $expected[$i]; - $this->assertStructure($project, false); + self::assertApiResponseTypeStructure('ProjectCollection', $project); $this->assertEquals($compare[1], $project['customer']); } } @@ -169,7 +188,7 @@ class ProjectControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('ProjectEntity', $result); } public function testNotFound() @@ -195,7 +214,7 @@ class ProjectControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('ProjectEntity', $result); $this->assertNotEmpty($result['id']); self::assertEquals('2018-02-08T13:02:54+0000', $result['orderDate']); self::assertEquals('2019-02-01T19:32:17+0000', $result['start']); @@ -214,7 +233,7 @@ class ProjectControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('ProjectEntity', $result); $this->assertNotEmpty($result['id']); self::assertEquals('foo', $result['name']); } @@ -265,7 +284,7 @@ class ProjectControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('ProjectEntity', $result); $this->assertNotEmpty($result['id']); } @@ -354,23 +373,4 @@ class ProjectControllerTest extends APIControllerBaseTest $project = $em->getRepository(Project::class)->find(1); $this->assertEquals('another,testing,bar', $project->getMetaField('metatestmock')->getValue()); } - - protected function assertStructure(array $result, $full = true) - { - $expectedKeys = [ - 'id', 'name', 'visible', 'customer', 'color', 'metaFields', 'parentTitle', 'start', 'end', 'teams' - ]; - - if ($full) { - $expectedKeys = array_merge($expectedKeys, [ - 'comment', 'budget', 'timeBudget', 'orderNumber', 'orderDate' - ]); - } - - $actual = array_keys($result); - sort($actual); - sort($expectedKeys); - - $this->assertEquals($expectedKeys, $actual, 'Project structure does not match'); - } } diff --git a/tests/API/RateControllerTestTrait.php b/tests/API/RateControllerTestTrait.php index ba509464..fb376fdb 100644 --- a/tests/API/RateControllerTestTrait.php +++ b/tests/API/RateControllerTestTrait.php @@ -192,6 +192,15 @@ trait RateControllerTestTrait $this->assertNotFoundForDelete($client, $this->getRateUrl(2, 1)); } + public function testDeleteNotAllowed() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); + $this->importTestRates(1); + + $this->request($client, $this->getRateUrl(1, 1), 'DELETE'); + $this->assertApiResponseAccessDenied($client->getResponse(), 'Access denied.'); + } + protected function assertRateStructure(array $result, $user = null) { $expectedKeys = [ diff --git a/tests/API/TagControllerTest.php b/tests/API/TagControllerTest.php index 1c78500e..db827438 100644 --- a/tests/API/TagControllerTest.php +++ b/tests/API/TagControllerTest.php @@ -66,14 +66,30 @@ class TagControllerTest extends APIControllerBaseTest $this->importTagFixtures($client); $data = [ 'name' => 'foo', + 'color' => '#000FFF' ]; $this->request($client, '/api/tags', 'POST', [], json_encode($data)); $this->assertTrue($client->getResponse()->isSuccessful()); $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('TagEntity', $result); $this->assertNotEmpty($result['id']); + self::assertEquals('#000FFF', $result['color']); + } + + public function testPostActionWithValidationErrors() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->importTagFixtures($client); + $data = [ + 'name' => '1', + 'color' => '11231231231', + ]; + $this->request($client, '/api/tags', 'POST', [], json_encode($data)); + $response = $client->getResponse(); + $this->assertEquals(400, $response->getStatusCode()); + $this->assertApiCallValidationError($response, ['name', 'color']); } public function testPostActionWithInvalidUser() @@ -128,21 +144,4 @@ class TagControllerTest extends APIControllerBaseTest { $this->assertEntityNotFoundForDelete(User::ROLE_ADMIN, '/api/tags/255'); } - - protected function assertStructure(array $result, $full = true) - { - $expectedKeys = [ - 'id', 'name', 'color' - ]; - - if ($full) { - $expectedKeys = array_merge($expectedKeys, []); - } - - $actual = array_keys($result); - sort($actual); - sort($expectedKeys); - - $this->assertEquals($expectedKeys, $actual, 'Tag structure does not match'); - } } diff --git a/tests/API/TeamControllerTest.php b/tests/API/TeamControllerTest.php index 7a7ed005..7da5dd62 100644 --- a/tests/API/TeamControllerTest.php +++ b/tests/API/TeamControllerTest.php @@ -59,7 +59,7 @@ class TeamControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); self::assertEquals(2, \count($result)); - $this->assertStructure($result[0], false); + self::assertApiResponseTypeStructure('TeamCollection', $result[0]); } public function testGetEntity() @@ -70,7 +70,7 @@ class TeamControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result, true); + self::assertApiResponseTypeStructure('TeamEntity', $result); } public function testNotFound() @@ -95,7 +95,7 @@ class TeamControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('TeamEntity', $result); $this->assertNotEmpty($result['id']); } @@ -114,6 +114,20 @@ class TeamControllerTest extends APIControllerBaseTest self::assertEquals('Access denied.', $json['message']); } + public function testPostActionWithValidationErrors() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'name' => '', + 'teamlead' => 9999, + ]; + $this->request($client, '/api/teams', 'POST', [], json_encode($data)); + + $response = $client->getResponse(); + $this->assertEquals(400, $response->getStatusCode()); + $this->assertApiCallValidationError($response, ['name', 'teamlead']); + } + public function testPatchAction() { $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); @@ -135,11 +149,34 @@ class TeamControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('TeamEntity', $result); $this->assertNotEmpty($result['id']); self::assertCount(4, $result['users']); } + public function testPatchActionWithValidationErrors() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'name' => 'foo', + 'teamlead' => 1, + ]; + $this->request($client, '/api/teams', 'POST', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + + $data = [ + 'name' => '1', + 'teamlead' => 9999, + 'users' => [9999] + ]; + $this->request($client, '/api/teams/' . $result['id'], 'PATCH', [], json_encode($data)); + + $response = $client->getResponse(); + $this->assertEquals(400, $response->getStatusCode()); + $this->assertApiCallValidationError($response, ['name', 'teamlead', 'users']); + } + public function testDeleteAction() { $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); @@ -148,7 +185,7 @@ class TeamControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('TeamEntity', $result); $this->assertNotEmpty($result['id']); $id = $result['id']; @@ -175,7 +212,7 @@ class TeamControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('TeamEntity', $result); self::assertCount(2, $result['users']); } @@ -238,7 +275,7 @@ class TeamControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('TeamEntity', $result); self::assertCount(3, $result['users']); } @@ -300,7 +337,7 @@ class TeamControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('TeamEntity', $result); self::assertCount(1, $result['customers']); self::assertEquals(1, $result['customers'][0]['id']); } @@ -381,7 +418,7 @@ class TeamControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('TeamEntity', $result); self::assertCount(0, $result['customers']); } @@ -427,13 +464,12 @@ class TeamControllerTest extends APIControllerBaseTest $this->assertTrue($client->getResponse()->isSuccessful()); $result = json_decode($client->getResponse()->getContent(), true); self::assertCount(0, $result['projects']); - $this->request($client, '/api/teams/' . $result['id'] . '/projects/1', 'POST'); $this->assertTrue($client->getResponse()->isSuccessful()); $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('TeamEntity', $result); self::assertCount(1, $result['projects']); self::assertEquals(1, $result['projects'][0]['id']); } @@ -520,7 +556,7 @@ class TeamControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('TeamEntity', $result); self::assertCount(0, $result['projects']); } @@ -554,23 +590,4 @@ class TeamControllerTest extends APIControllerBaseTest $json = json_decode($client->getResponse()->getContent(), true); self::assertEquals('Project is not assigned to the team', $json['message']); } - - protected function assertStructure(array $result, $full = true) - { - $expectedKeys = [ - 'id', 'name' - ]; - - if ($full) { - $expectedKeys = array_merge($expectedKeys, [ - 'teamlead', 'users', 'customers', 'projects' - ]); - } - - $actual = array_keys($result); - sort($actual); - sort($expectedKeys); - - self::assertEquals($expectedKeys, $actual, 'Team structure does not match'); - } } diff --git a/tests/API/TimesheetControllerTest.php b/tests/API/TimesheetControllerTest.php index 2f4494bf..16cd8e2b 100644 --- a/tests/API/TimesheetControllerTest.php +++ b/tests/API/TimesheetControllerTest.php @@ -59,7 +59,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertEquals(10, \count($result)); - $this->assertDefaultStructure($result[0], false); + self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); } public function testGetCollectionFull() @@ -72,8 +72,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertEquals(10, \count($result)); - $this->assertDefaultStructure($result[0], false); - $this->assertHasSubresources($result[0]); + self::assertApiResponseTypeStructure('TimesheetCollectionFull', $result[0]); } public function testGetCollectionForOtherUser() @@ -99,14 +98,13 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertEquals(10, \count($result)); - $this->assertDefaultStructure($result[0], false); + self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); } public function testGetCollectionForAllUser() { $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $this->importFixtureForUser(User::ROLE_USER); - $em = $this->getEntityManager(); $fixture = new TimesheetFixtures(); $fixture @@ -125,7 +123,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertEquals(17, \count($result)); - $this->assertDefaultStructure($result[0], false); + self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); } public function testGetCollectionForEmptyResult() @@ -170,7 +168,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertEquals(5, \count($result)); - $this->assertDefaultStructure($result[0], false); + self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); } public function testGetCollectionWithSingleParamsQuery() @@ -202,14 +200,13 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertEquals(5, \count($result)); - $this->assertDefaultStructure($result[0], false); + self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); } public function testExportedFilter() { $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER); - $em = $this->getEntityManager(); $fixture = new TimesheetFixtures(); $fixture @@ -240,7 +237,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertEquals(7, \count($result)); - $this->assertDefaultStructure($result[0], false); + self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); $query = [ 'page' => 1, @@ -256,7 +253,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertEquals(10, \count($result)); - $this->assertDefaultStructure($result[0], false); + self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); $query = [ 'page' => 1, @@ -270,7 +267,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertEquals(17, \count($result)); - $this->assertDefaultStructure($result[0], false); + self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); } public function testGetEntity() @@ -281,7 +278,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertDefaultStructure($result); + self::assertApiResponseTypeStructure('TimesheetEntity', $result); } public function testGetEntityAccessDenied() @@ -301,7 +298,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertDefaultStructure($result); + self::assertApiResponseTypeStructure('TimesheetEntity', $result); } public function testGetEntityNotFound() @@ -327,7 +324,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertDefaultStructure($result); + self::assertApiResponseTypeStructure('TimesheetEntity', $result); $this->assertNotEmpty($result['id']); $this->assertTrue($result['duration'] == 57600 || $result['duration'] == 57660); // 1 minute rounding might be applied $this->assertEquals(2016, $result['rate']); @@ -357,7 +354,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertDefaultStructure($result); + self::assertApiResponseTypeStructure('TimesheetEntity', $result); $this->assertNotEmpty($result['id']); $this->assertEquals($user->getId(), $result['user']); $this->assertNotEquals($admin->getId(), $result['user']); @@ -437,7 +434,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertDefaultStructure($result); + self::assertApiResponseTypeStructure('TimesheetEntity', $result); $this->assertNotEmpty($result['id']); $this->assertEquals(25200, $result['duration']); $this->assertEquals(1, $result['exported']); @@ -447,7 +444,6 @@ class TimesheetControllerTest extends APIControllerBaseTest { $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER); - $em = $this->getEntityManager(); $fixture = new TimesheetFixtures(); $fixture @@ -510,7 +506,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertDefaultStructure($result); + self::assertApiResponseTypeStructure('TimesheetEntity', $result); $this->assertNotEmpty($result['id']); $id = $result['id']; @@ -585,10 +581,9 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertTrue($client->getResponse()->isSuccessful()); } - public function testGetRecentCollectionWithSubresources() + public function testGetRecentAction() { $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); - $em = $this->getEntityManager(); $start = new \DateTime('-10 days'); @@ -614,14 +609,12 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertEquals(1, \count($result)); - $this->assertDefaultStructure($result[0], false); - $this->assertHasSubresources($result[0]); + self::assertApiResponseTypeStructure('TimesheetCollectionFull', $result[0]); } public function testActiveAction() { $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); - $em = $this->getEntityManager(); $start = new \DateTime('-10 days'); @@ -642,7 +635,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $results = json_decode($client->getResponse()->getContent(), true); $this->assertEquals(3, \count($results)); foreach ($results as $timesheet) { - $this->assertDefaultStructure($timesheet, false); + self::assertApiResponseTypeStructure('TimesheetCollectionFull', $timesheet); } } @@ -650,7 +643,6 @@ class TimesheetControllerTest extends APIControllerBaseTest { $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER); - $em = $this->getEntityManager(); $start = new \DateTime('-10 days'); @@ -668,6 +660,11 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->request($client, '/api/timesheets/11/stop', 'PATCH'); $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($result); + $this->assertNotEmpty($result); + self::assertApiResponseTypeStructure('TimesheetEntity', $result); + $em = $this->getEntityManager(); /** @var Timesheet $timesheet */ $timesheet = $em->getRepository(Timesheet::class)->find(1); @@ -692,7 +689,6 @@ class TimesheetControllerTest extends APIControllerBaseTest { $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER); - $em = $this->getEntityManager(); $start = new \DateTime('-10 days'); @@ -715,7 +711,6 @@ class TimesheetControllerTest extends APIControllerBaseTest { $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER); - $em = $this->getEntityManager(); $fixture = new TimesheetFixtures(); $fixture @@ -736,7 +731,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertEquals(5, \count($result)); - $this->assertDefaultStructure($result[0], false); + self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); $query = ['tags' => 'Test,Admin']; $this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query); @@ -745,7 +740,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertEquals(10, \count($result)); - $this->assertDefaultStructure($result[0], false); + self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); $query = ['tags' => 'Nothing-2-see,here']; $this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query); @@ -754,7 +749,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertEquals(20, \count($result)); - $this->assertDefaultStructure($result[0], false); + self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); } public function testRestartAction() @@ -772,7 +767,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertTrue($client->getResponse()->isSuccessful()); $result = json_decode($client->getResponse()->getContent(), true); - $this->assertDefaultStructure($result, true); + self::assertApiResponseTypeStructure('TimesheetEntity', $result); $this->assertEmpty($result['description']); $this->assertEmpty($result['tags']); @@ -812,7 +807,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertTrue($client->getResponse()->isSuccessful()); $result = json_decode($client->getResponse()->getContent(), true); - $this->assertDefaultStructure($result, true); + self::assertApiResponseTypeStructure('TimesheetEntity', $result); $this->assertEquals('foo', $result['description']); $this->assertEquals([['name' => 'sdfsdf', 'value' => 'nnnnn'], ['name' => '1234567890', 'value' => '1234567890']], $result['metaFields']); $this->assertEquals(['another', 'testing', 'bar'], $result['tags']); @@ -832,8 +827,6 @@ class TimesheetControllerTest extends APIControllerBaseTest { $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); - $em = $this->getEntityManager(); - $start = new \DateTime('-10 days'); $fixture = new TimesheetFixtures(); @@ -874,7 +867,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertDefaultStructure($result); + self::assertApiResponseTypeStructure('TimesheetEntity', $result); $this->assertNotEmpty($result['id']); $this->assertTrue($result['duration'] == 57600 || $result['duration'] == 57660); // 1 minute rounding might be applied $this->assertEquals(2016, $result['rate']); @@ -884,7 +877,7 @@ class TimesheetControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertDefaultStructure($result); + self::assertApiResponseTypeStructure('TimesheetEntity', $result); $this->assertNotEmpty($result['id']); $this->assertTrue($result['duration'] == 57600 || $result['duration'] == 57660); // 1 minute rounding might be applied $this->assertEquals(2016, $result['rate']); @@ -907,7 +900,8 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->request($client, '/api/timesheets/1/export', 'PATCH'); $this->assertTrue($client->getResponse()->isSuccessful()); - $this->assertDefaultStructure(json_decode($client->getResponse()->getContent(), true), true); + $result = json_decode($client->getResponse()->getContent(), true); + self::assertApiResponseTypeStructure('TimesheetEntity', $result); $em->clear(); /** @var Timesheet $timesheet */ @@ -988,46 +982,13 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + self::assertApiResponseTypeStructure('TimesheetEntity', $result); + $this->assertEquals(['name' => 'metatestmock', 'value' => 'another,testing,bar'], $result['metaFields'][0]); + $em = $this->getEntityManager(); /** @var Timesheet $timesheet */ $timesheet = $em->getRepository(Timesheet::class)->find(1); $this->assertEquals('another,testing,bar', $timesheet->getMetaField('metatestmock')->getValue()); } - - protected function assertDefaultStructure(array $result, $full = true) - { - $expectedKeys = [ - 'id', 'begin', 'end', 'duration', 'description', 'rate', 'activity', 'project', 'tags', 'user', 'metaFields', 'internalRate' - ]; - - if ($full) { - $expectedKeys = array_merge($expectedKeys, [ - 'exported', 'fixedRate', 'hourlyRate' - ]); - } - - $actual = array_keys($result); - sort($actual); - sort($expectedKeys); - - $this->assertEquals($expectedKeys, $actual, 'Timesheet structure does not match'); - } - - protected function assertHasSubresources(array $result) - { - $this->assertArrayHasKey('activity', $result); - $this->assertArrayHasKey('id', $result['activity']); - $this->assertArrayHasKey('name', $result['activity']); - $this->assertArrayHasKey('visible', $result['activity']); - $this->assertArrayHasKey('project', $result['activity']); - - $this->assertArrayHasKey('project', $result); - $this->assertArrayHasKey('id', $result['project']); - $this->assertArrayHasKey('name', $result['project']); - $this->assertArrayHasKey('visible', $result['project']); - $this->assertArrayHasKey('customer', $result['project']); - $this->assertArrayHasKey('id', $result['project']['customer']); - $this->assertArrayHasKey('name', $result['project']['customer']); - $this->assertArrayHasKey('visible', $result['project']['customer']); - } } diff --git a/tests/API/UserControllerTest.php b/tests/API/UserControllerTest.php index b45fd1e0..fbae5249 100644 --- a/tests/API/UserControllerTest.php +++ b/tests/API/UserControllerTest.php @@ -49,21 +49,21 @@ class UserControllerTest extends APIControllerBaseTest $this->assertNotEmpty($result); $this->assertEquals(7, \count($result)); foreach ($result as $user) { - $this->assertStructure($user, false); + self::assertApiResponseTypeStructure('UserCollection', $user); } } public function testGetCollectionWithQuery() { $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); - $this->assertAccessIsGranted($client, '/api/users', 'GET', ['visible' => 2, 'orderBy' => 'email', 'order' => 'DESC']); + $this->assertAccessIsGranted($client, '/api/users', 'GET', ['visible' => 2, 'orderBy' => 'email', 'order' => 'DESC', 'term' => 'chris']); $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertEquals(1, \count($result)); foreach ($result as $user) { - $this->assertStructure($user, false); + self::assertApiResponseTypeStructure('UserCollection', $user); } } @@ -77,7 +77,7 @@ class UserControllerTest extends APIControllerBaseTest $this->assertNotEmpty($result); $this->assertEquals(8, \count($result)); foreach ($result as $user) { - $this->assertStructure($user, false); + self::assertApiResponseTypeStructure('UserCollection', $user); } } @@ -88,7 +88,7 @@ class UserControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('UserEntity', $result); self::assertEquals('1', $result['id']); self::assertEquals('CFO', $result['title']); self::assertEquals('Clara Haynes', $result['alias']); @@ -101,7 +101,7 @@ class UserControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('UserEntity', $result); self::assertEquals('6', $result['id']); self::assertEquals('Super Administrator', $result['title']); self::assertEquals('', $result['alias']); @@ -125,7 +125,7 @@ class UserControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('UserEntity', $result); } public function testPostAction() @@ -150,7 +150,7 @@ class UserControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('UserEntity', $result); $this->assertNotEmpty($result['id']); self::assertEquals('foo', $result['username']); self::assertEquals('test123', $result['avatar']); @@ -161,6 +161,50 @@ class UserControllerTest extends APIControllerBaseTest self::assertEquals(['ROLE_TEAMLEAD', 'ROLE_ADMIN'], $result['roles']); } + public function testPostActionWithShortPassword() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); + $data = [ + 'username' => 'foo', + 'email' => 'foo@example.com', + 'avatar' => 'test123', + 'title' => 'asdfghjkl', + 'plainPassword' => '1234567', + 'enabled' => true, + 'language' => 'ru', + 'timezone' => 'Europe/Paris', + 'roles' => [ + 'ROLE_TEAMLEAD', + 'ROLE_ADMIN' + ], + ]; + $this->request($client, '/api/users', 'POST', [], json_encode($data)); + + $response = $client->getResponse(); + $this->assertEquals(400, $response->getStatusCode()); + $this->assertApiCallValidationError($response, ['plainPassword']); + } + + public function testPostActionWithValidationErrors() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); + $data = [ + 'username' => '', + 'email' => '', + 'plainPassword' => '123456', + 'language' => 'xx', + 'timezone' => 'XXX/YYY', + 'roles' => [ + 'ABC', + ], + ]; + $this->request($client, '/api/users', 'POST', [], json_encode($data)); + + $response = $client->getResponse(); + $this->assertEquals(400, $response->getStatusCode()); + $this->assertApiCallValidationError($response, ['username', 'email', 'plainPassword', 'language', 'timezone', 'roles']); + } + public function testPostActionWithInvalidUser() { $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); @@ -216,7 +260,7 @@ class UserControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); - $this->assertStructure($result); + self::assertApiResponseTypeStructure('UserEntity', $result); $this->assertNotEmpty($result['id']); self::assertEquals('foo', $result['username']); self::assertEquals('test321', $result['avatar']); @@ -227,21 +271,36 @@ class UserControllerTest extends APIControllerBaseTest self::assertEquals(['ROLE_TEAMLEAD'], $result['roles']); } - protected function assertStructure(array $result, $full = true) + public function testPatchActionWithUnknownUser() { - $expectedKeys = ['id', 'username', 'enabled', 'alias']; + $this->assertEntityNotFoundForPatch(User::ROLE_SUPER_ADMIN, '/api/users/255', []); + } - if ($full) { - $expectedKeys = array_merge( - $expectedKeys, - ['title', 'avatar', 'teams', 'roles', 'language', 'timezone'] - ); - } + public function testPatchActionWithInvalidUser() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); + $this->request($client, '/api/users/1', 'PATCH', [], json_encode(['avatar' => 'asdasd'])); + $this->assertApiResponseAccessDenied($client->getResponse(), 'Not allowed to edit user'); + } - $actual = array_keys($result); - sort($actual); - sort($expectedKeys); + public function testPatchActionWithValidationErrors() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); + $data = [ + 'username' => '1', // not existing in form + 'email' => '', + 'plainPassword' => '123456', // not existing in form + 'plainApiToken' => '123456', // not existing in form + 'language' => 'xx', + 'timezone' => 'XXX/YYY', + 'roles' => [ + 'ABC', + ], + ]; + $this->request($client, '/api/users/1', 'PATCH', [], json_encode($data)); - $this->assertEquals($expectedKeys, $actual, 'User structure does not match'); + $response = $client->getResponse(); + $this->assertEquals(400, $response->getStatusCode()); + $this->assertApiCallValidationError($response, ['email', 'language', 'timezone', 'roles'], true); } } diff --git a/tests/Controller/SecurityControllerTest.php b/tests/Controller/SecurityControllerTest.php index 0429930b..e07b3044 100644 --- a/tests/Controller/SecurityControllerTest.php +++ b/tests/Controller/SecurityControllerTest.php @@ -100,4 +100,67 @@ class SecurityControllerTest extends ControllerBaseTest $this->assertStringContainsString('

Congrats example, your account is now activated.

', $content); $this->assertStringContainsString('', $content); } + + /** + * @dataProvider getValidationTestData + */ + public function testRegisterActionWithValidationProblems(array $formData, array $validationFields) + { + $client = self::createClient(); + + $this->assertHasValidationError($client, '/register/', 'form[name=fos_user_registration_form]', $formData, $validationFields); + } + + public function getValidationTestData() + { + return [ + [ + // invalid fields: username, password_second, email + [ + 'fos_user_registration_form' => [ + 'username' => '', + 'plainPassword' => ['first' => 'sdfsdf123'], + 'email' => '', + ] + ], + [ + '#fos_user_registration_form_username', + '#fos_user_registration_form_username', + '#fos_user_registration_form_plainPassword_first', + '#fos_user_registration_form_email', + '#fos_user_registration_form_email', + ] + ], + // invalid fields: username, password, email + [ + [ + 'fos_user_registration_form' => [ + 'username' => 'x', + 'plainPassword' => ['first' => 'sdfsdf123', 'second' => 'sdfxxxxxxx'], + 'email' => 'ydfbvsdfgs', + ] + ], + [ + '#fos_user_registration_form_username', + '#fos_user_registration_form_username', + '#fos_user_registration_form_plainPassword_first', + '#fos_user_registration_form_email', + '#fos_user_registration_form_email', + ] + ], + // invalid fields: password (too short) + [ + [ + 'fos_user_registration_form' => [ + 'username' => 'test123', + 'plainPassword' => ['first' => 'test123', 'second' => 'test123'], + 'email' => 'ydfbvsdfgs@example.com', + ] + ], + [ + '#fos_user_registration_form_plainPassword_first', + ] + ], + ]; + } } diff --git a/tests/Controller/UserControllerTest.php b/tests/Controller/UserControllerTest.php index 4b556475..960b4fd6 100644 --- a/tests/Controller/UserControllerTest.php +++ b/tests/Controller/UserControllerTest.php @@ -76,7 +76,7 @@ class UserControllerTest extends ControllerBaseTest 'user_create' => [ 'username' => $username, 'alias' => $username, - 'plainPassword' => ['first' => 'abcdef', 'second' => 'abcdef'], + 'plainPassword' => ['first' => '12345678', 'second' => '12345678'], 'email' => 'foobar@example.com', 'enabled' => 1, ] @@ -198,7 +198,7 @@ class UserControllerTest extends ControllerBaseTest [ 'user_create' => [ 'username' => '', - 'plainPassword' => ['first' => 'sdfsdf'], + 'plainPassword' => ['first' => 'sdfsdf123'], 'alias' => 'ycvyxcb', 'title' => '34rtwrtewrt', 'avatar' => 'asdfawer', @@ -216,7 +216,7 @@ class UserControllerTest extends ControllerBaseTest [ 'user_create' => [ 'username' => 'x', - 'plainPassword' => ['first' => 'sdfsdf', 'second' => 'sdfxxx'], + 'plainPassword' => ['first' => 'sdfsdf123', 'second' => 'sdfxxxxxxx'], 'alias' => 'ycvyxcb', 'title' => '34rtwrtewrt', 'avatar' => 'asdfawer', @@ -229,6 +229,22 @@ class UserControllerTest extends ControllerBaseTest '#user_create_email', ] ], + // invalid fields: password (too short) + [ + [ + 'user_create' => [ + 'username' => 'test123', + 'plainPassword' => ['first' => 'test123', 'second' => 'test123'], + 'alias' => 'ycvyxcb', + 'title' => '34rtwrtewrt', + 'avatar' => 'asdfawer', + 'email' => 'ydfbvsdfgs@example.com', + ] + ], + [ + '#user_create_plainPassword_first', + ] + ], ]; } } diff --git a/tests/Entity/EntityValidationTestTrait.php b/tests/Entity/EntityValidationTestTrait.php index a67cfbd7..94710747 100644 --- a/tests/Entity/EntityValidationTestTrait.php +++ b/tests/Entity/EntityValidationTestTrait.php @@ -10,6 +10,7 @@ namespace App\Tests\Entity; use Symfony\Component\Validator\ConstraintViolationInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface; /** * Classes using this MUST extend \Symfony\Bundle\FrameworkBundle\Test\KernelTestCase @@ -20,12 +21,12 @@ trait EntityValidationTestTrait * @param object $entity * @param array|string $fieldNames */ - protected function assertHasViolationForField(object $entity, $fieldNames) + protected function assertHasViolationForField(object $entity, $fieldNames, $groups = null) { self::bootKernel(); + /** @var ValidatorInterface $validator */ $validator = static::$kernel->getContainer()->get('validator'); - - $violations = $validator->validate($entity); + $violations = $validator->validate($entity, null, $groups); if (!\is_array($fieldNames)) { $fieldNames = [$fieldNames]; @@ -55,12 +56,13 @@ trait EntityValidationTestTrait $this->assertEquals($expected, $countViolations, sprintf('Expected %s violations, found %s in %s.', $expected, $actual, implode(', ', array_keys($violatedFields)))); } - protected function assertHasNoViolations($entity) + protected function assertHasNoViolations($entity, $groups = null) { self::bootKernel(); + /** @var ValidatorInterface $validator */ $validator = static::$kernel->getContainer()->get('validator'); - $violations = $validator->validate($entity); + $violations = $validator->validate($entity, null, $groups); $actual = $violations->count(); $this->assertEquals(0, $actual, sprintf('Expected 0 violations, found %s.', $actual)); diff --git a/tests/Entity/UserValidationTest.php b/tests/Entity/UserValidationTest.php index 3fe04145..58e28e10 100644 --- a/tests/Entity/UserValidationTest.php +++ b/tests/Entity/UserValidationTest.php @@ -27,7 +27,6 @@ class UserValidationTest extends KernelTestCase [null, null], ['x', 'test@'], // too short username [str_pad('#', 61, '-'), 'test@x.'], // too long username - [str_pad('#', 61, '-'), 'test@x.', ['xxxxx']], // too short password and invalid role ]; } @@ -48,14 +47,34 @@ class UserValidationTest extends KernelTestCase $defaultFields[] = 'roles'; } - $this->assertHasViolationForField($user, $defaultFields); + $this->assertHasViolationForField($user, $defaultFields, ['Profile']); + } + + public function testInvalidRoles() + { + $user = new User(); + $user->setUsername('foo'); + $user->setEmail('foo@example.com'); + $user->setRoles(['xxxxxx']); + + $this->assertHasViolationForField($user, ['roles'], ['RolesUpdate']); + } + + public function testValidRoles() + { + $user = new User(); + $user->setUsername('foo'); + $user->setEmail('foo@example.com'); + $user->setRoles(['ROLE_TEAMLEAD']); + + $this->assertHasNoViolations($user, ['RolesUpdate']); } public function getValidTestData() { return [ - [str_pad('#', 3, '-'), 'test@x.x'], // shortest possible username - [str_pad('#', 60, '-'), 'test@x.x', ['ROLE_TEAMLEAD']], // longest possible password and valid role + [str_pad('#', 8, '-'), 'test@x.x'], // shortest possible username + [str_pad('#', 60, '-'), 'test@x.x'], // longest possible username ]; } @@ -71,6 +90,6 @@ class UserValidationTest extends KernelTestCase $user->setRoles($roles); } - $this->assertHasNoViolations($user); + $this->assertHasNoViolations($user, ['Profile']); } }