Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.57% covered (success)
93.57%
262 / 280
22.22% covered (danger)
22.22%
2 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
SeedTestDataCommand
93.57% covered (success)
93.57%
262 / 280
22.22% covered (danger)
22.22%
2 / 9
61.99
0.00% covered (danger)
0.00%
0 / 1
 configure
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 execute
87.80% covered (success)
87.80%
36 / 41
0.00% covered (danger)
0.00%
0 / 1
8.12
 loadFixture
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
4.43
 seedCategories
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
11
 seedFaqs
92.86% covered (success)
92.86%
39 / 42
0.00% covered (danger)
0.00%
0 / 1
10.04
 linkFaqToCategory
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 seedGlossary
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 seedNews
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
5
 purge
95.74% covered (success)
95.74%
90 / 94
0.00% covered (danger)
0.00%
0 / 1
16
1<?php
2
3/**
4 * Seeds phpMyFAQ with bilingual (DE + EN) test data about phpMyFAQ itself.
5 *
6 * All rows created by this command carry a unique author/email marker so that
7 * they can be safely removed again with the --fresh option without touching
8 * any real content.
9 *
10 * This Source Code Form is subject to the terms of the Mozilla Public License,
11 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
12 * obtain one at https://mozilla.org/MPL/2.0/.
13 *
14 * @package   phpMyFAQ
15 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
16 * @copyright 2026 phpMyFAQ Team
17 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
18 * @link      https://www.phpmyfaq.de
19 * @since     2026-04-19
20 */
21
22declare(strict_types=1);
23
24namespace phpMyFAQ\Command;
25
26use DateTime;
27use phpMyFAQ\Category;
28use phpMyFAQ\Category\Permission as CategoryPermission;
29use phpMyFAQ\Configuration;
30use phpMyFAQ\Database;
31use phpMyFAQ\Entity\CategoryEntity;
32use phpMyFAQ\Entity\FaqEntity;
33use phpMyFAQ\Entity\NewsMessage;
34use phpMyFAQ\Environment;
35use phpMyFAQ\Faq;
36use phpMyFAQ\Faq\Permission as FaqPermission;
37use phpMyFAQ\Glossary;
38use phpMyFAQ\News;
39use phpMyFAQ\Tags;
40use Symfony\Component\Console\Attribute\AsCommand;
41use Symfony\Component\Console\Command\Command;
42use Symfony\Component\Console\Input\InputInterface;
43use Symfony\Component\Console\Input\InputOption;
44use Symfony\Component\Console\Output\OutputInterface;
45use Symfony\Component\Console\Style\SymfonyStyle;
46use Throwable;
47
48#[AsCommand(
49    name: 'phpmyfaq:seed-testdata',
50    description: 'Seeds phpMyFAQ with bilingual (DE + EN) test data about phpMyFAQ itself',
51)]
52/* @mago-expect lint:kan-defect - seeds every entity type inline; acceptable for a dev-only command */
53class SeedTestDataCommand extends Command
54{
55    public const string AUTHOR = 'phpMyFAQ Test Seeder';
56
57    public const string EMAIL = 'test-seeder@phpmyfaq.local';
58
59    /** Sentinel id meaning "all users / guests" for FAQ and category permissions. */
60    private const int ALL_USERS = -1;
61
62    private const string FIXTURE_DIR = __DIR__ . '/Fixtures/testdata';
63
64    private Configuration $configuration;
65
66    protected function configure(): void
67    {
68        $this->addOption(
69            name: 'locale',
70            shortcut: null,
71            mode: InputOption::VALUE_OPTIONAL,
72            description: 'Comma-separated list of locales to seed (default: de,en)',
73            default: 'de,en',
74        )->addOption(
75            name: 'fresh',
76            shortcut: null,
77            mode: InputOption::VALUE_NONE,
78            description: 'Remove previously seeded test data before inserting new rows',
79        );
80    }
81
82    protected function execute(InputInterface $input, OutputInterface $output): int
83    {
84        $io = new SymfonyStyle($input, $output);
85        $io->title('phpMyFAQ Test Data Seeder');
86
87        // Resolve the configuration lazily so the console application can still
88        // be constructed before phpMyFAQ is installed (no database connection yet).
89        $this->configuration = Configuration::getConfigurationInstance();
90
91        if (Environment::getEnvironment() !== 'demo') {
92            $io->error(
93                'This command can only be run when APP_ENV=demo. Current environment: ' . Environment::getEnvironment(),
94            );
95            return Command::FAILURE;
96        }
97
98        /** @var string $localeOption */
99        $localeOption = $input->getOption('locale');
100        $locales = array_values(array_filter(array_map('trim', explode(',', $localeOption))));
101        if ($locales === []) {
102            $io->error('No valid locales provided.');
103            return Command::FAILURE;
104        }
105
106        $supported = ['de', 'en'];
107        foreach ($locales as $locale) {
108            if (in_array($locale, $supported, strict: true)) {
109                continue;
110            }
111            $io->error(sprintf('Locale "%s" is not supported. Available: %s', $locale, implode(', ', $supported)));
112            return Command::FAILURE;
113        }
114
115        try {
116            if ($input->getOption('fresh')) {
117                $removed = $this->purge();
118                $io->note(sprintf('Removed %d previously seeded rows.', $removed));
119            }
120
121            $categories = $this->loadFixture('categories.json');
122            $faqs = $this->loadFixture('faqs.json');
123            $glossary = $this->loadFixture('glossary.json');
124            $news = $this->loadFixture('news.json');
125
126            $categoryIdMap = $this->seedCategories($categories, $locales);
127            $io->writeln(sprintf(' <info>✓</info> %d categories seeded', count($categoryIdMap)));
128
129            $faqCount = $this->seedFaqs($faqs, $categoryIdMap, $locales);
130            $io->writeln(sprintf(' <info>✓</info> %d FAQs seeded', $faqCount));
131
132            $glossaryCount = $this->seedGlossary($glossary, $locales);
133            $io->writeln(sprintf(' <info>✓</info> %d glossary entries seeded', $glossaryCount));
134
135            $newsCount = $this->seedNews($news, $locales);
136            $io->writeln(sprintf(' <info>✓</info> %d news entries seeded', $newsCount));
137
138            $io->success(sprintf('Test data seeded successfully for locales: %s', implode(', ', $locales)));
139
140            return Command::SUCCESS;
141        } catch (Throwable $throwable) {
142            $io->error('Seeding failed: ' . $throwable->getMessage());
143            if ($output->isVerbose()) {
144                $io->writeln($throwable->getTraceAsString());
145            }
146
147            return Command::FAILURE;
148        }
149    }
150
151    /**
152     * @return array<int, array<string, mixed>>
153     */
154    private function loadFixture(string $filename): array
155    {
156        $path = self::FIXTURE_DIR . '/' . $filename;
157        if (!is_file($path)) {
158            throw new \RuntimeException(sprintf('Fixture file not found: %s', $path));
159        }
160
161        $raw = file_get_contents($path);
162        if ($raw === false) {
163            throw new \RuntimeException(sprintf('Unable to read fixture file: %s', $path));
164        }
165
166        $data = json_decode($raw, associative: true, depth: 16, flags: JSON_THROW_ON_ERROR);
167        if (!is_array($data)) {
168            throw new \RuntimeException(sprintf('Fixture file "%s" does not contain a JSON array.', $filename));
169        }
170
171        /** @var array<int, array<string, mixed>> $data */
172        return $data;
173    }
174
175    /**
176     * @param array<int, array<string, mixed>> $categories
177     * @param array<int, string>               $locales
178     *
179     * @return array<string, int> map of slug -> shared category id
180     */
181    private function seedCategories(array $categories, array $locales): array
182    {
183        $category = new Category($this->configuration);
184        $categoryPermission = new CategoryPermission($this->configuration);
185        $slugToId = [];
186
187        foreach ($categories as $definition) {
188            $slug = (string) $definition['slug'];
189            $parentSlug = $definition['parent'] ?? null;
190            $parentId = is_string($parentSlug) ? $slugToId[$parentSlug] ?? 0 : 0;
191
192            $sharedId = 0;
193            $primaryLocale = in_array('en', $locales, strict: true) ? 'en' : $locales[0];
194            $orderedLocales = array_merge(
195                [$primaryLocale],
196                array_values(array_filter($locales, static fn(string $l): bool => $l !== $primaryLocale)),
197            );
198
199            foreach ($orderedLocales as $locale) {
200                $translation = $definition['translations'][$locale] ?? null;
201                if (!is_array($translation)) {
202                    continue;
203                }
204
205                $entity = new CategoryEntity();
206                $entity->setLang($locale);
207                $entity->setParentId($parentId);
208                $entity->setName((string) $translation['name']);
209                $entity->setDescription((string) ($translation['description'] ?? ''));
210                $entity->setUserId(1);
211                $entity->setGroupId(-1);
212                $entity->setActive(true);
213                $entity->setShowHome(false);
214                $entity->setImage('');
215
216                if ($sharedId > 0) {
217                    $entity->setId($sharedId);
218                }
219
220                $newId = $category->create($entity);
221
222                if ($sharedId === 0 && is_int($newId) && $newId > 0) {
223                    $sharedId = $newId;
224                }
225            }
226
227            if ($sharedId > 0) {
228                $slugToId[$slug] = $sharedId;
229                // Grant access to all users and groups (-1) so guests can browse
230                // the category. The category tree requires the group grant; the
231                // user grant keeps it consistent with the admin "save" path.
232                $categoryPermission->add(CategoryPermission::USER, [$sharedId], [self::ALL_USERS]);
233                $categoryPermission->add(CategoryPermission::GROUP, [$sharedId], [self::ALL_USERS]);
234            }
235        }
236
237        return $slugToId;
238    }
239
240    /**
241     * @param array<int, array<string, mixed>> $faqs
242     * @param array<string, int>               $categoryIdMap
243     * @param array<int, string>               $locales
244     */
245    private function seedFaqs(array $faqs, array $categoryIdMap, array $locales): int
246    {
247        $faqService = new Faq($this->configuration);
248        $tagsService = new Tags($this->configuration);
249        $faqPermission = new FaqPermission($this->configuration);
250        $inserted = 0;
251
252        foreach ($faqs as $definition) {
253            $categorySlug = (string) $definition['category'];
254            $categoryId = $categoryIdMap[$categorySlug] ?? null;
255            if ($categoryId === null) {
256                continue;
257            }
258            /** @var array<int, string> $tags */
259            $tags = $definition['tags'] ?? [];
260
261            $sharedFaqId = 0;
262            foreach ($locales as $locale) {
263                $translation = $definition['translations'][$locale] ?? null;
264                if (!is_array($translation)) {
265                    continue;
266                }
267
268                $entity = new FaqEntity();
269                if ($sharedFaqId > 0) {
270                    $entity->setId($sharedFaqId);
271                }
272                $entity->setLanguage($locale);
273                $entity->setActive(true);
274                $entity->setSticky(false);
275                $entity->setComment(true);
276                $entity->setQuestion((string) $translation['question']);
277                $entity->setAnswer((string) $translation['answer']);
278                $entity->setKeywords((string) ($translation['keywords'] ?? ''));
279                $entity->setAuthor(self::AUTHOR);
280                $entity->setEmail(self::EMAIL);
281                $entity->setNotes('');
282                $entity->setCreatedDate(new DateTime());
283
284                $saved = $faqService->create($entity);
285                $faqId = $saved->getId();
286                if (!is_int($faqId) || $faqId <= 0) {
287                    continue;
288                }
289
290                if ($sharedFaqId === 0) {
291                    $sharedFaqId = $faqId;
292                    // Grant access to all users and groups (-1) so guests can read and find the FAQ.
293                    $faqPermission->add(FaqPermission::USER, $sharedFaqId, [self::ALL_USERS]);
294                    $faqPermission->add(FaqPermission::GROUP, $sharedFaqId, [self::ALL_USERS]);
295                }
296
297                $this->linkFaqToCategory($faqId, $locale, $categoryId);
298
299                if ($tags !== []) {
300                    $tagsService->create($faqId, $tags);
301                }
302
303                $inserted++;
304            }
305        }
306
307        return $inserted;
308    }
309
310    private function linkFaqToCategory(int $faqId, string $language, int $categoryId): void
311    {
312        $query = sprintf(
313            "INSERT INTO %sfaqcategoryrelations (category_id, category_lang, record_id, record_lang) VALUES (%d, '%s', %d, '%s')",
314            Database::getTablePrefix(),
315            $categoryId,
316            $this->configuration->getDb()->escape($language),
317            $faqId,
318            $this->configuration->getDb()->escape($language),
319        );
320
321        $this->configuration->getDb()->query($query);
322    }
323
324    /**
325     * @param array<int, array<string, mixed>> $entries
326     * @param array<int, string>               $locales
327     */
328    private function seedGlossary(array $entries, array $locales): int
329    {
330        $inserted = 0;
331        foreach ($entries as $entry) {
332            foreach ($locales as $locale) {
333                $translation = $entry['translations'][$locale] ?? null;
334                if (!is_array($translation)) {
335                    continue;
336                }
337
338                $glossary = new Glossary($this->configuration);
339                $glossary->setLanguage($locale);
340
341                if ($glossary->create((string) $translation['item'], (string) $translation['definition'])) {
342                    $inserted++;
343                }
344            }
345        }
346
347        return $inserted;
348    }
349
350    /**
351     * @param array<int, array<string, mixed>> $entries
352     * @param array<int, string>               $locales
353     */
354    private function seedNews(array $entries, array $locales): int
355    {
356        $newsService = new News($this->configuration);
357        $inserted = 0;
358
359        foreach ($entries as $entry) {
360            foreach ($locales as $locale) {
361                $translation = $entry['translations'][$locale] ?? null;
362                if (!is_array($translation)) {
363                    continue;
364                }
365
366                $message = new NewsMessage();
367                $message->setLanguage($locale);
368                $message->setHeader((string) $translation['header']);
369                $message->setMessage((string) $translation['message']);
370                $message->setAuthor(self::AUTHOR);
371                $message->setEmail(self::EMAIL);
372                $message->setActive(true);
373                $message->setComment(false);
374                $message->setCreated(new DateTime());
375                $message->setDateStart(new DateTime());
376                $message->setDateEnd(new DateTime('9999-12-31 23:59:59'));
377                $message->setLink('');
378                $message->setLinkTitle('');
379                $message->setLinkTarget('');
380
381                if ($newsService->create($message)) {
382                    $inserted++;
383                }
384            }
385        }
386
387        return $inserted;
388    }
389
390    /**
391     * Removes all rows previously inserted by this seeder, identified by the
392     * author/email marker. Returns the total number of affected rows.
393     */
394    private function purge(): int
395    {
396        $db = $this->configuration->getDb();
397        $prefix = Database::getTablePrefix();
398        $author = $db->escape(self::AUTHOR);
399        $email = $db->escape(self::EMAIL);
400
401        $removed = 0;
402
403        // Collect FAQ ids so related rows (tags, relations, votes, comments) can be cleaned up.
404        // Note: we fetch everything up-front to stay portable across drivers whose
405        // fetchArray() implementations signal "no more rows" differently (empty array vs null vs false).
406        $faqIds = [];
407        $faqResult = $db->query(sprintf(
408            "SELECT id, lang FROM %sfaqdata WHERE author = '%s' AND email = '%s'",
409            $prefix,
410            $author,
411            $email,
412        ));
413        $rows = $db->fetchAll($faqResult) ?? [];
414        foreach ($rows as $row) {
415            $id = $row->id ?? null;
416            $lang = $row->lang ?? null;
417            if ($id === null || $lang === null) {
418                continue;
419            }
420            $faqIds[] = ['id' => (int) $id, 'lang' => (string) $lang];
421        }
422
423        foreach ($faqIds as $faq) {
424            $db->query(sprintf(
425                "DELETE FROM %sfaqcategoryrelations WHERE record_id = %d AND record_lang = '%s'",
426                $prefix,
427                $faq['id'],
428                $db->escape($faq['lang']),
429            ));
430            $db->query(sprintf('DELETE FROM %sfaqdata_tags WHERE record_id = %d', $prefix, $faq['id']));
431            $db->query(sprintf('DELETE FROM %sfaqdata_user WHERE record_id = %d', $prefix, $faq['id']));
432            $db->query(sprintf('DELETE FROM %sfaqdata_group WHERE record_id = %d', $prefix, $faq['id']));
433            $db->query(sprintf(
434                "DELETE FROM %sfaqchanges WHERE beitrag = %d AND lang = '%s'",
435                $prefix,
436                $faq['id'],
437                $db->escape($faq['lang']),
438            ));
439            $db->query(sprintf(
440                "DELETE FROM %sfaqvisits WHERE id = %d AND lang = '%s'",
441                $prefix,
442                $faq['id'],
443                $db->escape($faq['lang']),
444            ));
445        }
446
447        $deletes = [
448            sprintf("DELETE FROM %sfaqdata WHERE author = '%s' AND email = '%s'", $prefix, $author, $email),
449            sprintf("DELETE FROM %sfaqnews WHERE author_name = '%s' AND author_email = '%s'", $prefix, $author, $email),
450        ];
451
452        foreach ($deletes as $query) {
453            $result = $db->query($query);
454            $removed += $db->affectedRows();
455        }
456
457        // Categories and glossary entries have no author field; detect them via the fixture definitions.
458        $categoryDefs = $this->loadFixture('categories.json');
459        foreach ($categoryDefs as $definition) {
460            $translations = is_array($definition['translations'] ?? null) ? $definition['translations'] : [];
461            foreach ($translations as $lang => $translation) {
462                if (!is_array($translation)) {
463                    continue;
464                }
465
466                $name = $db->escape((string) ($translation['name'] ?? ''));
467
468                // Remove the public access rows for these categories first.
469                $idResult = $db->query(sprintf(
470                    "SELECT id FROM %sfaqcategories WHERE name = '%s' AND lang = '%s'",
471                    $prefix,
472                    $name,
473                    $db->escape((string) $lang),
474                ));
475                foreach ($db->fetchAll($idResult) ?? [] as $idRow) {
476                    $categoryId = (int) ($idRow->id ?? 0);
477                    if ($categoryId === 0) {
478                        continue;
479                    }
480                    $db->query(sprintf('DELETE FROM %sfaqcategory_user WHERE category_id = %d', $prefix, $categoryId));
481                    $db->query(sprintf('DELETE FROM %sfaqcategory_group WHERE category_id = %d', $prefix, $categoryId));
482                }
483
484                $result = $db->query(sprintf(
485                    "DELETE FROM %sfaqcategories WHERE name = '%s' AND lang = '%s'",
486                    $prefix,
487                    $name,
488                    $db->escape((string) $lang),
489                ));
490                $removed += $db->affectedRows();
491            }
492        }
493
494        $glossaryDefs = $this->loadFixture('glossary.json');
495        foreach ($glossaryDefs as $entry) {
496            $glossaryTranslations = is_array($entry['translations'] ?? null) ? $entry['translations'] : [];
497            foreach ($glossaryTranslations as $lang => $translation) {
498                if (!is_array($translation)) {
499                    continue;
500                }
501
502                $item = $db->escape((string) ($translation['item'] ?? ''));
503                $result = $db->query(sprintf(
504                    "DELETE FROM %sfaqglossary WHERE item = '%s' AND lang = '%s'",
505                    $prefix,
506                    $item,
507                    $db->escape((string) $lang),
508                ));
509                $removed += $db->affectedRows();
510            }
511        }
512
513        // Prune orphaned tag definitions (tagging_name no longer referenced by any record).
514        $db->query(sprintf(
515            'DELETE FROM %sfaqtags WHERE tagging_id NOT IN (SELECT tagging_id FROM %sfaqdata_tags)',
516            $prefix,
517            $prefix,
518        ));
519
520        return $removed;
521    }
522}