$args */ private function field(string $name, string $type, array $args = [], array $extra = []): object { $field = Mockery::mock(); $field->allows('get_name')->andReturn($name); $field->allows('get_label')->andReturn(ucfirst($name)); $field->allows('get_type')->andReturn($type); $field->allows('is_required')->andReturn($extra['required'] ?? false); $field->allows('is_multi_value')->andReturn($extra['multi'] ?? false); $field->allows('get_related_object_type')->andReturn($extra['related_type'] ?? null); $field->allows('get_related_object_name')->andReturn($extra['related_name'] ?? null); $field->allows('get_arg')->andReturnUsing(static fn (string $key, $default = null) => $args[$key] ?? $default); return $field; } private function source(?object $pod): PodsFieldSource { return new PodsFieldSource(static fn (string $name) => $pod ?? false); } /** @return array> */ private function describe(): array { $pod = Mockery::mock(); $pod->allows('get_type')->andReturn('post_type'); $pod->allows('get_fields')->andReturn([ 'role' => $this->field('role', 'text', ['description' => 'Funktion im Team'], ['required' => true]), 'level' => $this->field('level', 'pick', [ 'pick_object' => 'custom-simple', 'pick_custom' => "junior|Junior\nsenior|Senior\nplain", ]), 'mentor' => $this->field('mentor', 'pick', ['pick_object' => 'post_type'], [ 'related_type' => 'post_type', 'related_name' => 'lit_person', ]), 'photos' => $this->field('photos', 'file', [], ['multi' => true]), 'intro' => $this->field('intro', 'heading'), 'mystery' => $this->field('mystery', 'fancy-addon-type'), ]); $fields = $this->source($pod)->fieldsFor('lit_person'); return array_column(array_map(static fn (FieldDefinition $f): array => $f->toArray(), $fields), null, 'key'); } public function testMapsPodsFieldTypes(): void { $fields = $this->describe(); self::assertSame(['role', 'level', 'mentor', 'photos', 'mystery'], array_keys($fields)); self::assertSame('string', $fields['role']['type']); self::assertTrue($fields['role']['required']); self::assertSame('Funktion im Team', $fields['role']['description']); self::assertSame('pods', $fields['role']['source']); self::assertSame(FieldDefinition::WRITABLE, $fields['role']['status']); self::assertSame('relation', $fields['mentor']['type']); self::assertSame(['object_type' => 'post_type', 'name' => 'lit_person'], $fields['mentor']['related']); self::assertSame('file', $fields['photos']['type']); self::assertTrue($fields['photos']['multiple']); self::assertSame(['object_type' => 'media', 'name' => null], $fields['photos']['related']); self::assertSame('unknown', $fields['mystery']['type']); self::assertSame(FieldDefinition::UNKNOWN, $fields['mystery']['status']); } public function testParsesCustomSimpleChoices(): void { self::assertSame( [ ['value' => 'junior', 'label' => 'Junior'], ['value' => 'senior', 'label' => 'Senior'], ['value' => 'plain', 'label' => 'plain'], ], $this->describe()['level']['choices'] ); } public function testIgnoresNonPodsAndNonPostTypePods(): void { $taxonomyPod = Mockery::mock(); $taxonomyPod->allows('get_type')->andReturn('taxonomy'); self::assertSame([], $this->source(null)->fieldsFor('page')); self::assertSame([], $this->source($taxonomyPod)->fieldsFor('genre')); } public function testReadsRelationsAsIdsAndOtherFieldsRaw(): void { Functions\when('get_post_type')->justReturn('lit_person'); $pod = Mockery::mock(); $pod->expects('field')->with(['name' => 'mentor', 'output' => 'ids', 'single' => true])->andReturn('12'); $pod->expects('field')->with(['name' => 'photos', 'output' => 'ids', 'single' => false])->andReturn(['3', '4']); $pod->expects('field')->with(['name' => 'role', 'single' => true, 'raw' => true])->andReturn('Leitung'); $source = new PodsFieldSource(null, static fn (string $type, int $id) => $pod); self::assertSame(12, $source->read(5, new FieldDefinition('mentor', 'Mentor', 'relation', 'pods', 'writable'))); self::assertSame( [3, 4], $source->read(5, new FieldDefinition('photos', 'Fotos', 'file', 'pods', 'writable', false, true)) ); self::assertSame('Leitung', $source->read(5, new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'))); } public function testWriteSavesThroughPodsAndMapsNullToEmpty(): void { $definitions = [ 'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'), 'photos' => new FieldDefinition('photos', 'Fotos', 'file', 'pods', 'writable', false, true), ]; $pod = Mockery::mock(); $pod->expects('save') ->with(['role' => '', 'photos' => []], null, null, ['error_mode' => 'exception']) ->andReturn(5); (new PodsFieldSource(null, static fn () => $pod)) ->write(5, 'lit_person', ['role' => null, 'photos' => null], $definitions); } public function testWriteFailureBecomesRuntimeException(): void { $pod = Mockery::mock(); $pod->allows('save')->andThrow(new Exception('Pods error')); $this->expectException(RuntimeException::class); (new PodsFieldSource(null, static fn () => $pod))->write(5, 'lit_person', ['role' => 'x'], []); } /** * Security audit M4: Pods field restrictions, as Pods' own view applies them (Pods::view): * hidden → hidden; read_only → readonly; restricted (admin_only, restrict_role/-capability) * and not permitted → readonly with read_only_restricted, else hidden. * * @param array $permitted Field name => pods_permission() (null = unrestricted). * @return array Field name => status. */ private function statuses(array $permitted): array { $pod = Mockery::mock(); $pod->allows('get_type')->andReturn('post_type'); $pod->allows('get_fields')->andReturn([ $this->field('open', 'text'), $this->field('internal', 'text', ['hidden' => '1']), $this->field('fixed', 'text', ['read_only' => 1]), $this->field('boss', 'text', ['admin_only' => '1']), $this->field('team', 'text', ['restrict_role' => '1', 'read_only_restricted' => '1']), $this->field('cap', 'number', ['restrict_capability' => '1']), ]); $source = new PodsFieldSource( static fn (string $name) => $pod, null, static fn (object $field): ?bool => $permitted[$field->get_name()] ?? null ); $statuses = []; foreach ($source->fieldsFor('lit_person') as $definition) { $statuses[$definition->key] = $definition->status; } return $statuses; } public function testFieldRestrictionsBecomeReadonlyOrHidden(): void { self::assertSame( [ 'open' => FieldDefinition::WRITABLE, 'internal' => FieldDefinition::HIDDEN, 'fixed' => FieldDefinition::READONLY, 'boss' => FieldDefinition::HIDDEN, 'team' => FieldDefinition::READONLY, 'cap' => FieldDefinition::HIDDEN, ], $this->statuses(['boss' => false, 'team' => false, 'cap' => false]) ); self::assertSame( ['open', 'internal', 'fixed', 'boss', 'team', 'cap'], array_keys($this->statuses([])), 'hidden fields stay in the schema (media usage search), only their status changes' ); $allowed = $this->statuses(['boss' => true, 'team' => true, 'cap' => true]); self::assertSame(FieldDefinition::WRITABLE, $allowed['boss']); self::assertSame(FieldDefinition::WRITABLE, $allowed['team']); self::assertSame(FieldDefinition::HIDDEN, $allowed['internal'], 'hidden is hidden for everyone, as in Pods'); } /** Re-check in write(): a restricted field is never handed to Pods' save(), which skips its own checks. */ public function testWriteRefusesRestrictedFields(): void { $pod = Mockery::mock(); $pod->expects('save')->never(); $definitions = [ 'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', FieldDefinition::WRITABLE), 'boss' => new FieldDefinition('boss', 'Boss', 'string', 'pods', FieldDefinition::HIDDEN), ]; $this->expectException(RuntimeException::class); (new PodsFieldSource(null, static fn () => $pod)) ->write(5, 'lit_person', ['role' => 'x', 'boss' => 'y'], $definitions); } }