From 95aae74466ed117236b4b99c0cb58852faff672c Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 22:29:15 +0200 Subject: [PATCH] feat: field validation, term resolution, post guard and revision points Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/WordPress/PostGuard.php | 79 ++++++++ src/Integrations/WordPress/RevisionPoint.php | 29 +++ .../WordPress/Schema/FieldValidator.php | 182 ++++++++++++++++++ .../WordPress/Schema/TermAssigner.php | 113 +++++++++++ tests/Stubs/WpPost.php | 37 ++++ tests/Stubs/WpTerm.php | 21 ++ .../Integrations/WordPress/PostGuardTest.php | 63 ++++++ .../WordPress/RevisionPointTest.php | 37 ++++ .../WordPress/Schema/FieldValidatorTest.php | 102 ++++++++++ .../WordPress/Schema/TermAssignerTest.php | 83 ++++++++ tests/bootstrap.php | 2 + 11 files changed, 748 insertions(+) create mode 100644 src/Integrations/WordPress/PostGuard.php create mode 100644 src/Integrations/WordPress/RevisionPoint.php create mode 100644 src/Integrations/WordPress/Schema/FieldValidator.php create mode 100644 src/Integrations/WordPress/Schema/TermAssigner.php create mode 100644 tests/Stubs/WpPost.php create mode 100644 tests/Stubs/WpTerm.php create mode 100644 tests/Unit/Integrations/WordPress/PostGuardTest.php create mode 100644 tests/Unit/Integrations/WordPress/RevisionPointTest.php create mode 100644 tests/Unit/Integrations/WordPress/Schema/FieldValidatorTest.php create mode 100644 tests/Unit/Integrations/WordPress/Schema/TermAssignerTest.php diff --git a/src/Integrations/WordPress/PostGuard.php b/src/Integrations/WordPress/PostGuard.php new file mode 100644 index 0000000..d247543 --- /dev/null +++ b/src/Integrations/WordPress/PostGuard.php @@ -0,0 +1,79 @@ + 'positive Ganzzahl erwartet'] + ); + } + + $post = get_post($id); + if (! $post instanceof WP_Post || ! $schema->isManaged((string) $post->post_type)) { + return Errors::notFound(sprintf('Eintrag %d nicht gefunden (oder kein unterstützter Inhaltstyp).', $id)); + } + + if (! current_user_can('edit_post', $id)) { + return Errors::forbidden(sprintf('Keine Berechtigung, Eintrag %d zu bearbeiten.', $id)); + } + + return $post; + } + + /** + * The brief declares this `: object|WP_Error`, but `object` already covers every + * class type, so PHP refuses `object` inside a union ("Type object cannot be + * part of a union type" – fatal at compile time). WP_Error is itself an object, + * so a plain `object` return type is accurate and still lets callers branch with + * `is_wp_error()` / `instanceof WP_Error`, exactly like editable() above. + */ + public static function postType(mixed $postType, ContentSchema $schema, string $cap): object + { + $name = is_string($postType) ? $postType : ''; + $object = $name !== '' && $schema->isManaged($name) ? get_post_type_object($name) : null; + if (! is_object($object)) { + return Errors::notFound( + sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $name), + 'Verfügbare Inhaltstypen über lit-content-types abrufen.' + ); + } + + $capability = isset($object->cap->{$cap}) ? (string) $object->cap->{$cap} : $cap; + if (! current_user_can($capability)) { + return Errors::forbidden(sprintf('Keine Berechtigung für Inhaltstyp "%s".', $name)); + } + + return $object; + } + + public static function id(mixed $value): ?int + { + if (is_int($value)) { + return $value > 0 ? $value : null; + } + + if (is_string($value) && ctype_digit($value) && (int) $value > 0) { + return (int) $value; + } + + return null; + } +} diff --git a/src/Integrations/WordPress/RevisionPoint.php b/src/Integrations/WordPress/RevisionPoint.php new file mode 100644 index 0000000..90504a5 --- /dev/null +++ b/src/Integrations/WordPress/RevisionPoint.php @@ -0,0 +1,29 @@ + 1]); + $newest = is_array($revisions) ? reset($revisions) : false; + + return is_object($newest) ? (int) $newest->ID : null; + } +} diff --git a/src/Integrations/WordPress/Schema/FieldValidator.php b/src/Integrations/WordPress/Schema/FieldValidator.php new file mode 100644 index 0000000..a9e989c --- /dev/null +++ b/src/Integrations/WordPress/Schema/FieldValidator.php @@ -0,0 +1,182 @@ + $definitions + * @param array $values + * @return array{values: array, errors: array, warnings: list} + */ + public function validate(array $definitions, array $values, bool $isCreate): array + { + $clean = []; + $errors = []; + $warnings = []; + + foreach ($values as $key => $value) { + $key = (string) $key; + $field = $definitions[$key] ?? null; + if ($field === null) { + $errors[$key] = 'Unbekanntes Feld; verfügbare Felder über lit-content-types abrufen.'; + continue; + } + + if ($field->status === FieldDefinition::READONLY) { + $errors[$key] = 'Feld ist schreibgeschützt.'; + continue; + } + + $error = $value === null ? null : $this->check($field, $value); + if ($error !== null) { + $errors[$key] = $error; + continue; + } + + if ($field->status === FieldDefinition::UNKNOWN) { + $warnings[] = sprintf( + 'Feld "%s" hat keinen bekannten Typ; der Wert wurde unverändert gespeichert.', + $key + ); + } + + $clean[$key] = $value; + } + + foreach ($definitions as $key => $field) { + if (! $field->required || isset($errors[$key])) { + continue; + } + + $provided = array_key_exists($key, $clean); + if ($isCreate && (! $provided || $this->isEmpty($clean[$key]))) { + $errors[$key] = 'Pflichtfeld fehlt.'; + } elseif (! $isCreate && $provided && $this->isEmpty($clean[$key])) { + $errors[$key] = 'Pflichtfeld darf nicht geleert werden.'; + } + } + + return ['values' => $clean, 'errors' => $errors, 'warnings' => $warnings]; + } + + private function check(FieldDefinition $field, mixed $value): ?string + { + if ($field->multiple && in_array($field->type, self::LIST_TYPES, true)) { + if (! is_array($value) || ! array_is_list($value)) { + return 'Liste erwartet.'; + } + + if ($field->type === 'array') { + return null; + } + + foreach ($value as $item) { + $error = $this->checkScalar($field, $item); + if ($error !== null) { + return $error; + } + } + + return null; + } + + return $this->checkScalar($field, $value); + } + + private function checkScalar(FieldDefinition $field, mixed $value): ?string + { + switch ($field->type) { + case 'string': + case 'text': + return is_string($value) ? null : 'Text erwartet.'; + case 'integer': + return is_int($value) ? null : 'Ganzzahl erwartet.'; + case 'number': + return (is_int($value) || is_float($value)) ? null : 'Zahl erwartet.'; + case 'boolean': + return is_bool($value) ? null : 'true oder false erwartet.'; + case 'date': + return $this->matchesFormat($value, 'Y-m-d') ? null : 'Datum im Format JJJJ-MM-TT erwartet.'; + case 'datetime': + return $this->matchesFormat($value, 'Y-m-d H:i:s') + ? null + : 'Datum und Uhrzeit im Format JJJJ-MM-TT HH:MM:SS erwartet.'; + case 'time': + return $this->matchesFormat($value, 'H:i:s') ? null : 'Uhrzeit im Format HH:MM:SS erwartet.'; + case 'choice': + return $this->checkChoice($field, $value); + case 'relation': + case 'file': + return $this->checkRelation($field, $value); + case 'array': + return (is_array($value) && array_is_list($value)) ? null : 'Liste erwartet.'; + case 'object': + return is_array($value) ? null : 'Objekt erwartet.'; + default: + return null; + } + } + + private function checkChoice(FieldDefinition $field, mixed $value): ?string + { + $allowed = array_column($field->choices ?? [], 'value'); + + return is_string($value) && in_array($value, $allowed, true) + ? null + : sprintf('Erlaubte Werte: %s.', implode(', ', $allowed)); + } + + private function checkRelation(FieldDefinition $field, mixed $value): ?string + { + if (! is_int($value) || $value <= 0) { + return 'ID (positive Ganzzahl) erwartet.'; + } + + $objectType = $field->related['object_type'] ?? ''; + if ($objectType === 'media') { + return get_post_type($value) === 'attachment' + ? null + : sprintf('%d ist kein Medium der Mediathek.', $value); + } + + if ($objectType === 'post_type') { + $actual = get_post_type($value); + if ($actual === false) { + return sprintf('Eintrag %d existiert nicht.', $value); + } + + $expected = $field->related['name'] ?? null; + if ($expected !== null && $actual !== $expected) { + return sprintf('Eintrag %d ist vom Typ %s, erwartet %s.', $value, $actual, $expected); + } + } + + return null; + } + + private function matchesFormat(mixed $value, string $format): bool + { + if (! is_string($value)) { + return false; + } + + $parsed = DateTimeImmutable::createFromFormat('!' . $format, $value); + + return $parsed !== false && $parsed->format($format) === $value; + } + + private function isEmpty(mixed $value): bool + { + return $value === null || $value === '' || $value === []; + } +} diff --git a/src/Integrations/WordPress/Schema/TermAssigner.php b/src/Integrations/WordPress/Schema/TermAssigner.php new file mode 100644 index 0000000..5e5bee4 --- /dev/null +++ b/src/Integrations/WordPress/Schema/TermAssigner.php @@ -0,0 +1,113 @@ + $terms + * @return array{terms: array>, errors: array} + */ + public function resolve(string $postType, array $terms): array + { + $allowed = get_object_taxonomies($postType, 'names'); + $resolved = []; + $errors = []; + + foreach ($terms as $taxonomy => $items) { + $taxonomy = (string) $taxonomy; + if (! in_array($taxonomy, $allowed, true)) { + $errors[$taxonomy] = 'Taxonomie gehört nicht zu diesem Inhaltstyp.'; + continue; + } + + $object = get_taxonomy($taxonomy); + if (! is_object($object) || ! current_user_can($object->cap->assign_terms)) { + $errors[$taxonomy] = 'Keine Berechtigung, Begriffe dieser Taxonomie zuzuweisen.'; + continue; + } + + $result = $this->resolveItems($taxonomy, is_array($items) ? $items : [$items], $object); + if (is_string($result)) { + $errors[$taxonomy] = $result; + continue; + } + + $resolved[$taxonomy] = $result; + } + + return ['terms' => $resolved, 'errors' => $errors]; + } + + /** + * @param array> $resolved Output of resolve(). + * @throws RuntimeException + */ + public function assign(int $postId, array $resolved): void + { + foreach ($resolved as $taxonomy => $items) { + $ids = []; + foreach ($items as $item) { + if (is_int($item)) { + $ids[] = $item; + continue; + } + + $created = wp_insert_term($item, $taxonomy); + if (is_wp_error($created)) { + throw new RuntimeException('Term could not be created: ' . $created->get_error_message()); + } + $ids[] = (int) $created['term_id']; + } + + $result = wp_set_object_terms($postId, $ids, $taxonomy, false); + if (is_wp_error($result)) { + throw new RuntimeException('Terms could not be assigned: ' . $result->get_error_message()); + } + } + } + + /** + * @param list $items + * @return list|string Resolved items or an error message. + */ + private function resolveItems(string $taxonomy, array $items, object $object): array|string + { + $resolved = []; + foreach ($items as $item) { + if (is_int($item)) { + if (! get_term($item, $taxonomy) instanceof WP_Term) { + return sprintf('Begriff %d existiert nicht.', $item); + } + $resolved[] = $item; + continue; + } + + if (! is_string($item) || trim($item) === '') { + return 'Begriffe als ID (Zahl) oder Name (Text) angeben.'; + } + + $existing = term_exists(trim($item), $taxonomy); + if (is_array($existing)) { + $resolved[] = (int) $existing['term_id']; + continue; + } + + if (! current_user_can($object->cap->edit_terms)) { + return sprintf('Begriff "%s" existiert nicht; keine Berechtigung, ihn anzulegen.', trim($item)); + } + $resolved[] = trim($item); + } + + return $resolved; + } +} diff --git a/tests/Stubs/WpPost.php b/tests/Stubs/WpPost.php new file mode 100644 index 0000000..47628d6 --- /dev/null +++ b/tests/Stubs/WpPost.php @@ -0,0 +1,37 @@ + $fields */ + public function __construct(array $fields = []) + { + foreach ($fields as $key => $value) { + $this->{$key} = $value; + } + } + } +} diff --git a/tests/Stubs/WpTerm.php b/tests/Stubs/WpTerm.php new file mode 100644 index 0000000..b1e294c --- /dev/null +++ b/tests/Stubs/WpTerm.php @@ -0,0 +1,21 @@ + $fields */ + public function __construct(array $fields = []) + { + foreach ($fields as $key => $value) { + $this->{$key} = $value; + } + } + } +} diff --git a/tests/Unit/Integrations/WordPress/PostGuardTest.php b/tests/Unit/Integrations/WordPress/PostGuardTest.php new file mode 100644 index 0000000..5397f2b --- /dev/null +++ b/tests/Unit/Integrations/WordPress/PostGuardTest.php @@ -0,0 +1,63 @@ +allows('isManaged')->andReturnUsing(static fn (string $type): bool => in_array($type, ['page', 'post'], true)); + + return $schema; + } + + public function testRejectsInvalidIds(): void + { + self::assertSame('lit_validation_failed', PostGuard::editable('abc', $this->schema())->get_error_code()); + self::assertSame('lit_validation_failed', PostGuard::editable(-3, $this->schema())->get_error_code()); + } + + public function testMissingOrUnmanagedPostsAreNotFound(): void + { + Functions\when('get_post')->alias(static fn (int $id) => $id === 8 ? new WP_Post(['ID' => 8, 'post_type' => 'et_pb_layout']) : null); + + self::assertSame('lit_not_found', PostGuard::editable(5, $this->schema())->get_error_code()); + self::assertSame('lit_not_found', PostGuard::editable(8, $this->schema())->get_error_code()); + } + + public function testRequiresEditRightsAndReturnsThePost(): void + { + $post = new WP_Post(['ID' => 5, 'post_type' => 'page']); + Functions\when('get_post')->justReturn($post); + Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $id === 5); + + self::assertSame($post, PostGuard::editable('5', $this->schema())); + + Functions\when('current_user_can')->justReturn(false); + self::assertSame('lit_forbidden', PostGuard::editable(5, $this->schema())->get_error_code()); + } + + public function testPostTypeChecksAvailabilityAndCapability(): void + { + $object = (object) ['cap' => (object) ['create_posts' => 'edit_pages']]; + Functions\when('get_post_type_object')->justReturn($object); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_pages'); + + self::assertSame('lit_not_found', PostGuard::postType('et_pb_layout', $this->schema(), 'create_posts')->get_error_code()); + self::assertSame('lit_not_found', PostGuard::postType(null, $this->schema(), 'create_posts')->get_error_code()); + self::assertSame($object, PostGuard::postType('page', $this->schema(), 'create_posts')); + + Functions\when('current_user_can')->justReturn(false); + self::assertSame('lit_forbidden', PostGuard::postType('page', $this->schema(), 'create_posts')->get_error_code()); + } +} diff --git a/tests/Unit/Integrations/WordPress/RevisionPointTest.php b/tests/Unit/Integrations/WordPress/RevisionPointTest.php new file mode 100644 index 0000000..b4b262c --- /dev/null +++ b/tests/Unit/Integrations/WordPress/RevisionPointTest.php @@ -0,0 +1,37 @@ +once()->with(5)->andReturnUsing(static function () use (&$order) { + $order[] = 'save'; + return 811; + }); + Functions\expect('wp_get_post_revisions')->once()->with(5, ['posts_per_page' => 1]) + ->andReturnUsing(static function () use (&$order): array { + $order[] = 'latest'; + return [811 => new WP_Post(['ID' => 811, 'post_type' => 'revision'])]; + }); + + self::assertSame(811, RevisionPoint::capture(5)); + self::assertSame(['save', 'latest'], $order); + } + + public function testLatestIsNullWithoutRevisions(): void + { + Functions\when('wp_get_post_revisions')->justReturn([]); + + self::assertNull(RevisionPoint::latest(5)); + } +} diff --git a/tests/Unit/Integrations/WordPress/Schema/FieldValidatorTest.php b/tests/Unit/Integrations/WordPress/Schema/FieldValidatorTest.php new file mode 100644 index 0000000..986fc1e --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Schema/FieldValidatorTest.php @@ -0,0 +1,102 @@ + */ + private function definitions(): array + { + return [ + 'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable', true), + 'level' => new FieldDefinition('level', 'Stufe', 'choice', 'pods', 'writable', false, false, [ + ['value' => 'junior', 'label' => 'Junior'], + ['value' => 'senior', 'label' => 'Senior'], + ]), + 'mentor' => new FieldDefinition('mentor', 'Mentor', 'relation', 'pods', 'writable', false, false, null, [ + 'object_type' => 'post_type', + 'name' => 'lit_person', + ]), + 'photos' => new FieldDefinition('photos', 'Fotos', 'file', 'pods', 'writable', false, true, null, [ + 'object_type' => 'media', + 'name' => null, + ]), + 'start' => new FieldDefinition('start', 'Beginn', 'date', 'pods', 'writable'), + 'secret' => new FieldDefinition('_secret', '_secret', 'string', 'meta', 'readonly'), + 'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'), + ]; + } + + public function testValidValuesPassUnchanged(): void + { + Functions\when('get_post_type')->alias(static fn (int $id) => [12 => 'lit_person', 3 => 'attachment'][$id] ?? false); + $values = ['role' => 'Leitung', 'level' => 'senior', 'mentor' => 12, 'photos' => [3], 'start' => '2026-10-01']; + + $result = (new FieldValidator())->validate($this->definitions(), $values, true); + + self::assertSame([], $result['errors']); + self::assertSame($values, $result['values']); + } + + public function testRequiredFieldMustBeSetOnCreateAndNotClearedOnUpdate(): void + { + $validator = new FieldValidator(); + + self::assertSame('Pflichtfeld fehlt.', $validator->validate($this->definitions(), [], true)['errors']['role']); + self::assertSame( + 'Pflichtfeld darf nicht geleert werden.', + $validator->validate($this->definitions(), ['role' => ''], false)['errors']['role'] + ); + self::assertSame([], $validator->validate($this->definitions(), ['level' => 'junior'], false)['errors']); + } + + public function testUnknownReadonlyAndInvalidValuesAreReported(): void + { + Functions\when('get_post_type')->alias(static fn (int $id) => [7 => 'page', 3 => 'attachment'][$id] ?? false); + + $errors = (new FieldValidator())->validate($this->definitions(), [ + 'role' => 'x', + 'nope' => 1, + 'secret' => 'x', + 'level' => 'expert', + 'mentor' => 7, + 'photos' => 3, + 'start' => '01.10.2026', + ], false)['errors']; + + self::assertStringContainsString('lit-content-types', $errors['nope']); + self::assertSame('Feld ist schreibgeschützt.', $errors['secret']); + self::assertSame('Erlaubte Werte: junior, senior.', $errors['level']); + self::assertSame('Eintrag 7 ist vom Typ page, erwartet lit_person.', $errors['mentor']); + self::assertSame('Liste erwartet.', $errors['photos']); + self::assertSame('Datum im Format JJJJ-MM-TT erwartet.', $errors['start']); + self::assertArrayNotHasKey('role', $errors); + } + + public function testMissingRelatedPostAndNonMediaAreRejected(): void + { + Functions\when('get_post_type')->alias(static fn (int $id) => [5 => 'page'][$id] ?? false); + + $errors = (new FieldValidator())->validate($this->definitions(), ['mentor' => 99, 'photos' => [5]], false)['errors']; + + self::assertSame('Eintrag 99 existiert nicht.', $errors['mentor']); + self::assertSame('5 ist kein Medium der Mediathek.', $errors['photos']); + } + + public function testUnknownTypedFieldsAreAcceptedWithAWarningAndNullClears(): void + { + $result = (new FieldValidator())->validate($this->definitions(), ['city' => ['any' => 'thing'], 'level' => null], false); + + self::assertSame([], $result['errors']); + self::assertSame(['city' => ['any' => 'thing'], 'level' => null], $result['values']); + self::assertCount(1, $result['warnings']); + self::assertStringContainsString('city', $result['warnings'][0]); + } +} diff --git a/tests/Unit/Integrations/WordPress/Schema/TermAssignerTest.php b/tests/Unit/Integrations/WordPress/Schema/TermAssignerTest.php new file mode 100644 index 0000000..cb4ceb6 --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Schema/TermAssignerTest.php @@ -0,0 +1,83 @@ +justReturn(['category', 'post_tag']); + Functions\when('get_taxonomy')->justReturn((object) [ + 'cap' => (object) ['assign_terms' => 'assign_categories', 'edit_terms' => 'manage_categories'], + ]); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => match ($cap) { + 'assign_categories' => $mayAssign, + 'manage_categories' => $mayEditTerms, + default => false, + }); + Functions\when('get_term')->alias(static fn (int $id) => $id === 4 ? new WP_Term(['term_id' => 4]) : null); + Functions\when('term_exists')->alias( + static fn (string $name) => $name === 'News' ? ['term_id' => '9', 'term_taxonomy_id' => '9'] : null + ); + } + + public function testResolvesIdsExistingNamesAndNewNames(): void + { + $this->stubTaxonomies(); + + $result = (new TermAssigner())->resolve('post', ['category' => [4, 'News', 'Neu']]); + + self::assertSame([], $result['errors']); + self::assertSame(['category' => [4, 9, 'Neu']], $result['terms']); + } + + public function testReportsForeignTaxonomyMissingIdsAndMissingRights(): void + { + $this->stubTaxonomies(false); + + $result = (new TermAssigner())->resolve('post', [ + 'genre' => [1], + 'category' => [77], + 'post_tag' => ['Unbekannt'], + ]); + + self::assertSame('Taxonomie gehört nicht zu diesem Inhaltstyp.', $result['errors']['genre']); + self::assertSame('Begriff 77 existiert nicht.', $result['errors']['category']); + self::assertStringContainsString('keine Berechtigung', $result['errors']['post_tag']); + } + + public function testAssignRightsAreRequired(): void + { + $this->stubTaxonomies(true, false); + + $result = (new TermAssigner())->resolve('post', ['category' => [4]]); + + self::assertSame('Keine Berechtigung, Begriffe dieser Taxonomie zuzuweisen.', $result['errors']['category']); + } + + public function testAssignCreatesNewTermsAndReplacesTheAssignment(): void + { + Functions\expect('wp_insert_term')->once()->with('Neu', 'category')->andReturn(['term_id' => 15]); + Functions\expect('wp_set_object_terms')->once()->with(5, [4, 15], 'category', false)->andReturn([4, 15]); + + (new TermAssigner())->assign(5, ['category' => [4, 'Neu']]); + } + + public function testAssignFailureThrows(): void + { + Functions\when('wp_set_object_terms')->justReturn(new WP_Error('x', 'kaputt')); + + $this->expectException(RuntimeException::class); + + (new TermAssigner())->assign(5, ['category' => [4]]); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 15ea6ca..db56fb5 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -11,3 +11,5 @@ require_once __DIR__ . '/Stubs/WpError.php'; require_once __DIR__ . '/Stubs/WpUser.php'; require_once __DIR__ . '/Stubs/WpRestRequest.php'; require_once __DIR__ . '/Stubs/Wpdb.php'; +require_once __DIR__ . '/Stubs/WpPost.php'; +require_once __DIR__ . '/Stubs/WpTerm.php';