diff --git a/README.md b/README.md index ee7c260c..be2e8fec 100644 --- a/README.md +++ b/README.md @@ -68,11 +68,13 @@ Requirements: Create your first new board: ```bash -php bin/qi board:create test --phpbb 3.3 --db mariadb --port 8081 --populate extension-dev +php bin/qi board:create test --phpbb 3.3 --db mariadb --port 8081 --populate development ``` The QuickInstall CLI targets phpBB 3.2+ installer-based boards. phpBB 3.0/3.1 remain legacy-web-app territory and are not planned for the QuickInstall CLI. +All fixture presets run through one phpBB-aware seeding runtime with shared manifests, reset behavior, generated-file tracking, and version compatibility. + If you prefer a browser workflow, start the QuickInstall Dashboard UI: ```bash @@ -99,6 +101,7 @@ This project is maintained by the phpBB Customisations Team. - Originally created by Igor “igorw” Wiedler in the summer of 2007. - Maintained by Jari “tumba25” Kanerva from March 2010 to March 2015. - Maintained by Matt “MattF” Friedman since 2016. +- Thanks to Vinny for creating [phpBB Style Tester](https://github.com/vinny/phpbb-style-tester), which inspired QuickInstall's development seed preset. - Thanks to the phpBB.com MOD team (especially Josh, aka “A_Jelly_Doughnut”) for AutoMOD. - Thanks to the beta testers! - Thanks to the phpBB community including phpBB.com, startrekguide.com, and phpBBModders.net! diff --git a/composer.json b/composer.json index 3f7509e7..de8ba09b 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,10 @@ { "name": "Jari Kanerva (tumba25)", "role": "Maintained from March 2010 to March 2015" + }, + { + "name": "vinny", + "role": "Created phpBB Style Tester, which inspired the QuickInstall development seed preset" } ], "require": { diff --git a/docs/sandbox-cli.md b/docs/sandbox-cli.md index 98e819ab..e223a61c 100644 --- a/docs/sandbox-cli.md +++ b/docs/sandbox-cli.md @@ -112,16 +112,16 @@ Create a small empty board: php bin/qi board:create clean --phpbb 3.3 --db mariadb --port 8081 --populate none ``` -Create a board with extension-development fixtures: +Create a board with comprehensive development fixtures: ```bash -php bin/qi board:create extdev --phpbb 3.3.17 --db mariadb --port 8082 --populate extension-dev +php bin/qi board:create dev --phpbb 3.3.17 --db mariadb --port 8082 --populate development ``` Create a board with phpBB debug output enabled: ```bash -php bin/qi board:create debug --phpbb 3.3 --db mariadb --port 8085 --populate extension-dev --debug +php bin/qi board:create debug --phpbb 3.3 --db mariadb --port 8085 --populate development --debug ``` Create an older supported phpBB 3.2 board: @@ -172,42 +172,44 @@ php bin/qi board:create demo --phpbb 3.3 --db mariadb --port 8081 --populate tin ## Fixture Presets -Fixture seeding populates a board with categories, forums, users, topics, and replies. For non-tiny presets, it also adds a few seeded users to Global Moderators and Newly Registered Users. Newly registered users are kept at zero posts. It does not create custom groups, permission matrices, or attachments. +Fixture seeding populates a board with repeatable test data. The `development` preset favors meaningful phpBB states over raw volume; use `load-test` when topic and post volume is the primary goal. Use `--populate ` during `board:create`: ```bash -php bin/qi board:create demo --populate extension-dev +php bin/qi board:create demo --populate development ``` Available presets: -| Preset | Description | -|-----------------|----------------------------------------------------------------------| -| `none` | No seed data | -| `tiny` | 3 users, 1 category, 2 forums, 2 topics, 2 replies per topic | -| `extension-dev` | 10 users, 2 categories, 6 forums, 25 topics, 10 replies per topic | -| `load-test` | 100 users, 4 categories, 20 forums, 100 topics, 20 replies per topic | -| `random` | Random counts up to load-test size | +| Preset | Description | +|---------------|---------------------------------------------------------------------------------------------------------------------------| +| `none` | No seed data | +| `tiny` | 3 users, 1 category, 2 forums, 2 topics, 2 replies per topic | +| `development` | 25 users, 12 forums, 90 posts, plus rich sample content and states ideal for developing and testing extensions and styles | +| `load-test` | 100 users, 4 categories, 20 forums, 100 topics, 20 replies per topic | +| `random` | Random counts up to load-test size | -Fixture seeding is supported for MariaDB, MySQL, and PostgreSQL boards. SQLite boards currently support `--populate none` only; phpBB's posting and permission APIs can hold SQLite write locks too long for reliable fixture generation. +Every seeded preset is tracked by a preset-and-seed-specific manifest under the board's `store/` directory. This lets `--reset` and `--replace` remove only the database rows and physical files owned by that exact preset and seed. A missing manifest means there is no tracked seed data to remove. + +Fixture seeding is supported for MariaDB, MySQL, and PostgreSQL boards. SQLite boards support the `tiny` and `development` presets; use another database for the heavier `load-test` and `random` presets. You can seed again manually: ```bash -php bin/qi board:seed demo --preset extension-dev --seed 1 +php bin/qi board:seed demo --preset development --seed 1 ``` Replace seed data: ```bash -php bin/qi board:seed demo --preset extension-dev --seed 1 --replace +php bin/qi board:seed demo --preset development --seed 1 --replace ``` Remove seed data: ```bash -php bin/qi board:seed demo --preset extension-dev --seed 1 --reset +php bin/qi board:seed demo --preset development --seed 1 --reset ``` `--seed` is a repeatable random seed number. Use the same seed to get the same fixture shape. diff --git a/src/QuickInstall/Sandbox/Application.php b/src/QuickInstall/Sandbox/Application.php index 299b9207..1f7fdd71 100644 --- a/src/QuickInstall/Sandbox/Application.php +++ b/src/QuickInstall/Sandbox/Application.php @@ -287,6 +287,10 @@ private function boardCreate(array $args): int $populate = $cli->option('populate', 'none'); $debug = $cli->has('debug'); $this->validateBoardCreateOptions($db, $port, $populate); + if (SeedPresetCatalog::isDeprecated($populate)) + { + echo 'Warning: ' . SeedPresetCatalog::deprecationMessage() . "\n"; + } $created = (new BoardService($this->project, $this->sandboxOutput()))->create($name, $version, $db, $port, $populate, $debug, $cli->has('replace')); $paths = $created['paths']; @@ -519,12 +523,20 @@ private function boardSeed(array $args): int $name = $cli->argument(0); if ($name === null) { - throw new InvalidArgumentException('Usage: qi board:seed [--preset tiny|extension-dev|load-test|random] [--seed N] [--reset|--replace]'); + throw new InvalidArgumentException('Usage: qi board:seed --preset tiny|development|load-test|random [--seed N] [--reset|--replace]'); } - $preset = $cli->option('preset', 'extension-dev'); + $preset = trim($cli->option('preset', '')); + if ($preset === '') + { + throw new InvalidArgumentException('--preset is required. Choose one of: ' . implode(', ', SeedPresetCatalog::visible()) . '.'); + } $seed = (int) $cli->option('seed', '1'); $this->validatePreset($preset); + if (SeedPresetCatalog::isDeprecated($preset)) + { + echo 'Warning: ' . SeedPresetCatalog::deprecationMessage() . "\n"; + } if ($seed < 1) { throw new InvalidArgumentException('--seed must be a positive integer.'); @@ -569,18 +581,15 @@ private function validateBoardCreateOptions(string $db, int $port, string $popul $this->validatePreset($populate); } - if ($db === 'sqlite' && $populate !== 'none') + if ($db === 'sqlite' && !SeedPresetCatalog::supportsSqlitePopulate($populate)) { - throw new InvalidArgumentException('SQLite boards currently support --populate none only. Use mariadb, mysql, or postgres for fixture seeding.'); + throw new InvalidArgumentException('SQLite boards support --populate none, tiny, or development only. Use mariadb, mysql, or postgres for heavier fixture presets.'); } } private function validatePreset(string $preset): void { - if (!in_array($preset, ['tiny', 'extension-dev', 'load-test', 'random'], true)) - { - throw new InvalidArgumentException('Preset must be one of: tiny, extension-dev, load-test, random.'); - } + SeedPresetCatalog::validate($preset); } private function extMount(array $args): int @@ -907,7 +916,7 @@ private function help(array $args = []): void echo ' ' . $this->style('qi [arguments] [options]', '1;36') . "\n"; echo ' ' . $this->style('qi help [command]', '1;36') . "\n\n"; echo $this->style('Common workflow:', '1;33') . "\n"; - echo ' ' . $this->style('qi board:create demo --phpbb 3.3 --db mariadb --port 8081 --populate extension-dev', '1;36') . "\n"; + echo ' ' . $this->style('qi board:create demo --phpbb 3.3 --db mariadb --port 8081 --populate development', '1;36') . "\n"; echo ' ' . $this->style('qi board:start demo', '1;36') . "\n\n"; foreach ($commands as $group => $items) @@ -991,13 +1000,13 @@ private function helpCommands(): array '--phpbb VERSION' => 'phpBB selector. Examples: latest, 3.3, 3.3.17, 3.2, master. Default: latest.', '--db DB' => 'Database engine. One of: mariadb, mysql, postgres, sqlite. Default: mariadb.', '--port PORT' => 'Local browser port. Default: 8080.', - '--populate PRESET' => 'Seed preset. One of: none, tiny, extension-dev, load-test, random. Default: none.', + '--populate PRESET' => 'Seed preset. One of: none, tiny, development, load-test, random. Default: none.', '--debug' => 'Enable phpBB debug settings after install.', '--replace' => 'Destroy an existing board with the same name before creating the new one.', ], 'examples' => [ 'board:create demo --phpbb 3.3 --db mariadb --port 8081', - 'board:create extdev --phpbb 3.3.17 --populate extension-dev --debug', + 'board:create dev --phpbb 3.3.17 --populate development --debug', ], ], 'board:start' => [ @@ -1050,21 +1059,21 @@ private function helpCommands(): array ], 'board:seed' => [ 'title' => 'board:seed', - 'usage' => 'board:seed [--preset tiny|extension-dev|load-test|random] [--seed N] [--reset|--replace]', + 'usage' => 'board:seed --preset tiny|development|load-test|random [--seed N] [--reset|--replace]', 'summary' => 'Add, replace, or remove fixture content.', - 'description' => 'Seeds categories, forums, users, topics, and replies on an installed board. SQLite boards only support reset.', + 'description' => 'Seeds repeatable phpBB development or load fixtures on an installed board. SQLite supports tiny and development presets.', 'arguments' => [ '' => 'Required board name.', ], 'options' => [ - '--preset PRESET' => 'Fixture preset. One of: tiny, extension-dev, load-test, random. Default: extension-dev.', + '--preset PRESET' => 'Required fixture preset. One of: tiny, development, load-test, random.', '--seed N' => 'Positive random seed number for repeatable fixture shape. Default: 1.', '--replace' => 'Remove existing QuickInstall seed data, then seed again.', '--reset' => 'Remove existing QuickInstall seed data without adding new data.', ], 'examples' => [ - 'board:seed demo --preset extension-dev --seed 1', - 'board:seed demo --preset extension-dev --replace', + 'board:seed demo --preset development --seed 1', + 'board:seed demo --preset development --replace', ], ], ], diff --git a/src/QuickInstall/Sandbox/BoardRunner.php b/src/QuickInstall/Sandbox/BoardRunner.php index 1066892b..8100397c 100644 --- a/src/QuickInstall/Sandbox/BoardRunner.php +++ b/src/QuickInstall/Sandbox/BoardRunner.php @@ -39,9 +39,9 @@ public function start(string $name): void $this->output->write("Enabling phpBB debug config...\n"); $this->enableDebug($name); } - if (($board['db'] ?? '') === 'sqlite' && ($board['populate'] ?? 'none') !== 'none') + if (($board['db'] ?? '') === 'sqlite' && !SeedPresetCatalog::supportsSqlitePopulate($board['populate'] ?? 'none')) { - throw new RuntimeException('SQLite boards currently support populate:none only. Use mariadb, mysql, or postgres for seeded boards.'); + throw new RuntimeException('SQLite boards support populate:none, tiny, or development only. Use mariadb, mysql, or postgres for heavier fixture presets.'); } $this->seedIfNeeded($name, $board['populate'] ?? 'none'); $this->waitUntilHttpReady($name, $board['url'] ?? ''); @@ -394,12 +394,11 @@ protected function serviceState(string $name, string $service): string protected function runSeeder(string $name, string $preset, int $seed, string $action = 'seed'): void { - $writer = new SeederWriter($this->project); - $script = $writer->write($name); - + $package = (new SeederWriter($this->project))->write($name); $this->output->write("Running seed preset: $preset\n"); - $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'cp', $script, 'web:/tmp/qi_seed.php']); - $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'exec', '-T', 'web', 'timeout', '300', 'php', '-d', 'memory_limit=512M', '/tmp/qi_seed.php', $preset, (string) $seed, $action]); + $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'exec', '-T', 'web', 'mkdir', '-p', '/tmp/qi-seed-runtime']); + $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'cp', $package . '/.', 'web:/tmp/qi-seed-runtime']); + $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'exec', '-T', '-e', 'QUICKINSTALL_SEED_RUNTIME=1', 'web', 'timeout', '300', 'php', '-d', 'memory_limit=512M', '/tmp/qi-seed-runtime/run.php', $preset, (string) $seed, $action]); } protected function seedMarker(string $name, string $preset): string diff --git a/src/QuickInstall/Sandbox/BoardService.php b/src/QuickInstall/Sandbox/BoardService.php index c27d4e50..521bb177 100644 --- a/src/QuickInstall/Sandbox/BoardService.php +++ b/src/QuickInstall/Sandbox/BoardService.php @@ -316,9 +316,11 @@ public function destroy(string $name): void public function seed(string $name, string $preset, int $seed, string $action): void { $board = $this->project->board($name); - if (($board['db'] ?? '') === 'sqlite' && $action !== 'reset') + if (($board['db'] ?? '') === 'sqlite' + && $action !== 'reset' + && !SeedPresetCatalog::supportsSqliteSeed($preset)) { - throw new InvalidArgumentException('SQLite boards do not support fixture seeding. Use --reset to remove partial seed data, or use mariadb, mysql, or postgres for seeded boards.'); + throw new InvalidArgumentException('SQLite boards support tiny and development fixture presets only. Use --reset to remove seed data, or use mariadb, mysql, or postgres for heavier presets.'); } $runner = $this->createBoardRunner(); diff --git a/src/QuickInstall/Sandbox/SeedPresetCatalog.php b/src/QuickInstall/Sandbox/SeedPresetCatalog.php new file mode 100644 index 00000000..654168e8 --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedPresetCatalog.php @@ -0,0 +1,57 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox; + +use InvalidArgumentException; + +/** Defines accepted, visible, and deprecated fixture presets. */ +class SeedPresetCatalog +{ + /** @return string[] */ + public static function accepted(): array + { + return ['tiny', 'development', 'extension-dev', 'load-test', 'random']; + } + + /** @return string[] */ + public static function visible(): array + { + return ['tiny', 'development', 'load-test', 'random']; + } + + public static function supportsSqlitePopulate(string $preset): bool + { + return $preset === 'none' || self::supportsSqliteSeed($preset); + } + + public static function supportsSqliteSeed(string $preset): bool + { + return in_array($preset, ['tiny', 'development'], true); + } + + public static function validate(string $preset): void + { + if (!in_array($preset, self::accepted(), true)) + { + throw new InvalidArgumentException('Preset must be one of: ' . implode(', ', self::visible()) . '.'); + } + } + + public static function isDeprecated(string $preset): bool + { + return $preset === 'extension-dev'; + } + + public static function deprecationMessage(): string + { + return 'Preset extension-dev is deprecated and hidden from preset lists; use development for comprehensive development fixtures.'; + } +} diff --git a/src/QuickInstall/Sandbox/SeedRuntime/ContentBuilder.php b/src/QuickInstall/Sandbox/SeedRuntime/ContentBuilder.php new file mode 100644 index 00000000..eb15e471 --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedRuntime/ContentBuilder.php @@ -0,0 +1,605 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * @noinspection PhpMissingFieldTypeInspection + */ + +namespace QuickInstallSeed; + +use RuntimeException; + +class ContentBuilder extends BaseBuilder +{ + /** @var array */ + private $users; + + /** @var array */ + private $forums; + + /** @var array */ + private $posters; + + /** @var int */ + private $posterIndex = 0; + + public function seed(array $users, array $forums): array + { + $this->users = $users; + $this->forums = $forums; + $this->posters = $users['posters']; + $items = []; + $iconId = $this->firstIcon(); + + $items['announcement'] = $this->createTopic( + $forums['news'], + $users['admin'], + 'Announcement topic', + 'This announcement is shown more prominently than a regular topic in this forum.', + POST_ANNOUNCE + ); + $items['sticky'] = $this->createTopic( + $forums['news'], + $users['admin_2'], + 'Sticky topic', + 'This sticky stays near the top of the topic list so important information is easy to find.', + POST_STICKY + ); + $items['normal'] = $this->createTopic( + $forums['lobby'], + $users['regular'], + 'Normal topic', + 'This is a regular topic with no special status. Members can read it and add replies normally.' + ); + $items['locked'] = $this->createTopic( + $forums['lobby'], + $users['admin'], + 'Locked topic', + 'This topic has been locked. Members can still read it, but they cannot add new replies.' + ); + $this->context->db->sql_query('UPDATE ' . TOPICS_TABLE . ' + SET topic_status = ' . ITEM_LOCKED . ' + WHERE topic_id = ' . $items['locked']['topic_id']); + $this->addModeratorLog( + $users['admin'], + $forums['lobby'], + $items['locked']['topic_id'], + 'LOG_LOCK', + [$this->context->prefix . 'Locked topic'] + ); + $items['icon'] = $this->createTopic( + $forums['lobby'], + $users['regular_2'], + 'Topic with icon', + 'This regular topic has an icon beside its title so you can check how topic icons are displayed.', + POST_NORMAL, + ['icon_id' => $iconId] + ); + $items['global'] = $this->createTopic( + $forums['lobby'], + $users['moderator'], + 'Global announcement', + 'This global announcement can appear across the board instead of belonging to only one forum.', + POST_GLOBAL + ); + $items['moved_target'] = $this->createTopic( + $forums['lobby'], + $users['regular_3'], + 'Moved topic destination', + 'This is the destination of a moved topic. The original forum contains a redirect that points here.' + ); + $items['moved_redirect'] = [ + 'topic_id' => $this->createMovedRedirect($forums['read'], $items['moved_target']['topic_id']), + 'post_id' => 0, + ]; + $this->addModeratorLog( + $users['moderator'], + $forums['read'], + $items['moved_target']['topic_id'], + 'LOG_MOVE', + [$this->forumName($forums['read']), $this->forumName($forums['lobby'])] + ); + + $sectionNames = [ + 'news' => 'News forum', + 'lobby' => 'Lobby forum', + 'read' => 'Read forum', + 'unread' => 'Unread forum', + 'parent' => 'Parent forum', + 'locked_forum' => 'Locked forum', + 'password' => 'Password-protected forum', + 'private' => 'Private forum', + 'icon' => 'Forum with icon', + 'child_a' => 'Child subforum A', + 'child_b' => 'Child subforum B', + ]; + foreach ($forums as $key => $forumId) + { + if (in_array($key, ['link', 'empty'], true)) + { + continue; + } + for ($number = 1; $number <= 3; $number++) + { + $this->createTopic( + $forumId, + $this->nextPoster(), + sprintf('%s topic %d', $sectionNames[$key] ?? ucfirst(str_replace('_', ' ', $key)), $number), + sprintf( + 'This is topic %d in the %s. It has a short opening message and works like a regular discussion.', + $number, + $sectionNames[$key] ?? ucfirst(str_replace('_', ' ', $key)) + ) + ); + } + } + + $items += $this->seedRichPosts(); + $items += $this->seedPolls(); + $this->context->setMeta('content', $items); + $this->context->restoreUser(); + return $items; + } + + public function seedVolume(array $forums, array $users, int $topicCount, int $replyCount): void + { + if (!$forums || !$users) + { + throw new RuntimeException('Seed preset requires forums and posting users.'); + } + + for ($topic = 1; $topic <= $topicCount; $topic++) + { + $forumId = $forums[($topic - 1) % count($forums)]; + $label = sprintf('Topic %03d', $topic); + $item = $this->createTopic( + $forumId, + $users[($topic - 1) % count($users)], + $label, + sprintf('This is topic %d generated by the %s preset.', $topic, $this->context->preset) + ); + + for ($reply = 1; $reply <= $replyCount; $reply++) + { + $this->createReply( + $item['topic_id'], + $forumId, + $users[($topic + $reply - 1) % count($users)], + $label, + sprintf('This is reply %d of %d in topic %d.', $reply, $replyCount, $topic), + sprintf('page-%02d', $reply) + ); + } + + if ($topic % 10 === 0) + { + $this->context->saveManifest(); + $GLOBALS['cache']->purge(); + gc_collect_cycles(); + } + } + $this->context->restoreUser(); + } + + private function seedRichPosts(): array + { + $items = []; + $items['bbcode'] = $this->createTopic( + $this->forums['lobby'], + $this->users['regular'], + 'BBCode and smiley showcase', + 'This topic includes common BBCode, lists, a code block, a quote, links, and smileys.' + ); + $bbcode = 'This reply shows several formatting options in one place. + +[b]Bold text[/b], [i]italic text[/i], [u]underlined text[/u], and [s]strikethrough text[/s]. +[color=red]Red text[/color], [color=#00AA00]green text[/color], and [color=blue]blue text[/color]. +[size=50]Smaller text[/size] and [size=150]larger text[/size]. + +[quote="Sample user"]This quote also includes [url=https://www.phpbb.com/]a link[/url].[/quote] + +[code]function welcome($name) { + return "Welcome, " . $name; +}[/code] + +[list] +[*]First item +[*]Second item +[list=a] +[*]First alphabetical item +[*]Second alphabetical item +[/list] +[/list] + +[list=1] +[*]First numbered item +[*]Second numbered item +[/list] + +Common smileys: :) :D ;) :( :o :? 8-) :lol: :x :P :oops: :cry: :evil: :twisted: :roll: :!: :idea: :arrow: :geek: :ugeek:'; + $items['bbcode_reply'] = [ + 'topic_id' => $items['bbcode']['topic_id'], + 'post_id' => $this->createReply( + $items['bbcode']['topic_id'], + $this->forums['lobby'], + $this->users['moderator'], + 'BBCode and smiley showcase', + $bbcode, + 'bbcode' + ), + ]; + + $items['quotes'] = $this->createTopic( + $this->forums['lobby'], + $this->users['regular_2'], + 'Deep quote nesting', + 'This topic contains a reply with three quote levels so deeply nested quotes are easy to find.' + ); + $this->createReply( + $items['quotes']['topic_id'], + $this->forums['lobby'], + $this->users['admin'], + 'Deep quote nesting', + '[quote="User one"][quote="User two"][quote="User three"]Innermost quote.[/quote]Second-level reply.[/quote]First-level reply.[/quote]This text appears after all three quote levels.', + 'quotes' + ); + + $items['overflow'] = $this->createTopic( + $this->forums['lobby'], + $this->users['regular_3'], + 'Long content and overflow', + 'This topic contains a long link and a wide code block so wrapping and horizontal overflow are easy to find.' + ); + $this->createReply( + $items['overflow']['topic_id'], + $this->forums['lobby'], + $this->users['moderator_2'], + 'Long content and overflow', + 'This unbroken word checks that long text wraps without stretching the page: +SupercalifragilisticexpialidociousSupercalifragilisticexpialidociousSupercalifragilisticexpialidociousSupercalifragilisticexpialidocious + +This long address checks link wrapping: +https://example.test/events/summer-community-picnic/registration/this-is-an-intentionally-long-address-without-any-natural-break-points + +[code]------------------------------------------------------------------------------------------------------------------------ +This line is intentionally wide and should remain readable without breaking the surrounding page. +------------------------------------------------------------------------------------------------------------------------[/code]', + 'overflow' + ); + + $items['pagination'] = $this->createTopic( + $this->forums['lobby'], + $this->users['admin'], + 'Pagination topic', + 'This topic has 40 approved replies plus one reply waiting for approval, creating multiple topic pages.' + ); + for ($number = 1; $number <= 40; $number++) + { + $this->createReply( + $items['pagination']['topic_id'], + $this->forums['lobby'], + $this->nextPoster(), + 'Pagination topic', + sprintf('This is pagination reply %d of 40.', $number), + sprintf('page-%02d', $number) + ); + } + $unapproved = $this->createReply( + $items['pagination']['topic_id'], + $this->forums['lobby'], + $this->users['regular_3'], + 'Pagination topic', + 'This reply is waiting for moderator approval.', + 'unapproved' + ); + $this->context->db->sql_query('UPDATE ' . POSTS_TABLE . ' + SET post_visibility = ' . ITEM_UNAPPROVED . ' + WHERE post_id = ' . $unapproved); + $this->context->db->sql_query('UPDATE ' . TOPICS_TABLE . ' + SET topic_posts_approved = CASE WHEN topic_posts_approved > 0 THEN topic_posts_approved - 1 ELSE 0 END, + topic_posts_unapproved = topic_posts_unapproved + 1 + WHERE topic_id = ' . $items['pagination']['topic_id']); + $this->context->addId('unapproved_posts', $unapproved); + $items['unapproved'] = ['topic_id' => $items['pagination']['topic_id'], 'post_id' => $unapproved]; + + return $items; + } + + private function seedPolls(): array + { + $activePoll = [ + 'poll_title' => 'Which board area are you reviewing?', + 'poll_options' => ['Extensions', 'Styles', 'Permissions', 'Notifications'], + 'poll_max_options' => 1, + 'poll_length' => 0, + 'poll_vote_change' => 1, + 'poll_start' => time(), + ]; + $votedPoll = [ + 'poll_title' => 'How much sample content do you prefer?', + 'poll_options' => ['Focused', 'Balanced', 'Dense', 'Maximum'], + 'poll_max_options' => 1, + 'poll_length' => 0, + 'poll_vote_change' => 0, + 'poll_start' => time(), + ]; + + $items = []; + $items['active_poll'] = $this->createTopic( + $this->forums['lobby'], + $this->users['admin'], + 'Active poll', + 'This poll has no votes yet and allows members to change their vote.', + POST_NORMAL, + [], + $activePoll + ); + $items['voted_poll'] = $this->createTopic( + $this->forums['lobby'], + $this->users['admin_2'], + 'Poll with results', + 'This poll already has six votes spread across four choices.', + POST_NORMAL, + [], + $votedPoll + ); + $this->injectVotes($items['voted_poll']['topic_id']); + return $items; + } + + private function injectVotes(int $topicId): void + { + $db = $this->context->db; + $result = $db->sql_query('SELECT poll_option_id FROM ' . POLL_OPTIONS_TABLE . ' + WHERE topic_id = ' . $topicId . ' + ORDER BY poll_option_id ASC'); + $options = []; + while ($row = $db->sql_fetchrow($result)) + { + $options[] = (int) $row['poll_option_id']; + } + $db->sql_freeresult($result); + $voters = array_slice($this->posters, 0, 6); + $distribution = [0, 0, 1, 1, 2, 3]; + $totals = array_fill_keys($options, 0); + foreach ($voters as $index => $userId) + { + $optionId = $options[$distribution[$index]] ?? 0; + if (!$optionId) + { + continue; + } + $db->sql_query('INSERT INTO ' . POLL_VOTES_TABLE . ' ' . $db->sql_build_array('INSERT', [ + 'topic_id' => $topicId, + 'poll_option_id' => $optionId, + 'vote_user_id' => $userId, + 'vote_user_ip' => '127.0.0.1', + ])); + $totals[$optionId]++; + } + foreach ($totals as $optionId => $total) + { + $db->sql_query('UPDATE ' . POLL_OPTIONS_TABLE . ' + SET poll_option_total = ' . $total . ' + WHERE topic_id = ' . $topicId . ' AND poll_option_id = ' . $optionId); + } + $db->sql_query('UPDATE ' . TOPICS_TABLE . ' SET poll_last_vote = ' . time() . ' WHERE topic_id = ' . $topicId); + } + + private function createTopic( + int $forumId, + int $userId, + string $label, + string $message, + int $topicType = POST_NORMAL, + array $additional = [], + array $poll = [] + ): array + { + $db = $this->context->db; + $subject = $this->context->prefix . $label; + $result = $db->sql_query_limit( + 'SELECT topic_id, topic_first_post_id FROM ' . TOPICS_TABLE . " + WHERE topic_title = '" . $db->sql_escape($subject) . "'", + 1 + ); + $row = $db->sql_fetchrow($result); + $db->sql_freeresult($result); + if ($row) + { + $topicId = (int) $row['topic_id']; + $postId = (int) $row['topic_first_post_id']; + $this->context->addId('topics', $topicId); + $this->context->addId('posts', $postId); + return ['topic_id' => $topicId, 'post_id' => $postId]; + } + + $this->context->switchUser($userId); + $data = $this->postData($forumId, 0, $subject, $message); + $data = array_merge($data, $additional); + submit_post('post', $subject, $this->context->user->data['username'], $topicType, $poll, $data); + $topicId = (int) ($data['topic_id'] ?? 0); + $postId = (int) ($data['post_id'] ?? 0); + if (!$topicId || !$postId) + { + throw new RuntimeException("Unable to create seed topic: $subject"); + } + $this->context->addId('topics', $topicId); + $this->context->addId('posts', $postId); + return ['topic_id' => $topicId, 'post_id' => $postId]; + } + + private function createReply( + int $topicId, + int $forumId, + int $userId, + string $label, + string $message, + string $key + ): int + { + $db = $this->context->db; + /** @noinspection PhpStrFunctionsInspection */ + /** @noinspection StrStartsWithCanBeUsedInspection */ + $suffix = strpos($key, 'page-') === 0 ? ' — reply ' . substr($key, 5) : ''; + $subject = $this->context->prefix . 'Re: ' . $label . $suffix; + $result = $db->sql_query_limit( + 'SELECT post_id FROM ' . POSTS_TABLE . " + WHERE topic_id = $topicId + AND post_subject = '" . $db->sql_escape($subject) . "'", + 1 + ); + $postId = (int) $db->sql_fetchfield('post_id'); + $db->sql_freeresult($result); + if ($postId) + { + $this->context->addId('posts', $postId); + return $postId; + } + + $this->context->switchUser($userId); + $data = $this->postData($forumId, $topicId, $subject, $message); + $poll = []; + submit_post('reply', $subject, $this->context->user->data['username'], POST_NORMAL, $poll, $data); + $postId = (int) ($data['post_id'] ?? 0); + if (!$postId) + { + throw new RuntimeException("Unable to create seed reply: $subject"); + } + $this->context->addId('posts', $postId); + return $postId; + } + + private function postData(int $forumId, int $topicId, string $subject, string $message): array + { + $db = $this->context->db; + $uid = $bitfield = ''; + $options = 7; + generate_text_for_storage($message, $uid, $bitfield, $options, true, true, true); + $result = $db->sql_query_limit('SELECT forum_name FROM ' . FORUMS_TABLE . ' WHERE forum_id = ' . $forumId, 1); + $forumName = (string) $db->sql_fetchfield('forum_name'); + $db->sql_freeresult($result); + + return [ + 'forum_id' => $forumId, + 'topic_id' => $topicId, + 'icon_id' => 0, + 'topic_title' => $subject, + 'topic_time_limit' => 0, + 'poster_id' => (int) $this->context->user->data['user_id'], + 'enable_bbcode' => true, + 'enable_smilies' => true, + 'enable_urls' => true, + 'enable_sig' => true, + 'message' => $message, + 'message_md5' => md5($message), + 'bbcode_bitfield' => $bitfield, + 'bbcode_uid' => $uid, + 'post_edit_locked' => 0, + 'notify_set' => false, + 'notify' => false, + 'post_time' => time(), + 'forum_name' => $forumName, + 'enable_indexing' => true, + 'force_approved_state' => true, + ]; + } + + private function createMovedRedirect(int $forumId, int $targetTopicId): int + { + $db = $this->context->db; + $subject = $this->context->prefix . 'Moved topic redirect'; + $result = $db->sql_query_limit( + 'SELECT topic_id FROM ' . TOPICS_TABLE . ' + WHERE forum_id = ' . $forumId . ' + AND topic_status = ' . ITEM_MOVED . ' + AND topic_moved_id = ' . $targetTopicId, + 1 + ); + $id = (int) $db->sql_fetchfield('topic_id'); + $db->sql_freeresult($result); + if (!$id) + { + $db->sql_query('INSERT INTO ' . TOPICS_TABLE . ' ' . $db->sql_build_array('INSERT', [ + 'forum_id' => $forumId, + 'icon_id' => 0, + 'topic_title' => $subject, + 'topic_poster' => $this->users['regular_3'], + 'topic_time' => time(), + 'topic_status' => ITEM_MOVED, + 'topic_type' => POST_NORMAL, + 'topic_moved_id' => $targetTopicId, + 'topic_visibility' => ITEM_APPROVED, + ])); + $id = $this->lastInsertedId(); + } + $this->context->addId('topics', $id); + return $id; + } + + private function addModeratorLog( + int $userId, + int $forumId, + int $topicId, + string $operation, + array $data + ): void + { + $db = $this->context->db; + $result = $db->sql_query_limit('SELECT log_id FROM ' . LOG_TABLE . " + WHERE log_type = " . LOG_MOD . ' + AND forum_id = ' . $forumId . ' + AND topic_id = ' . $topicId . " + AND log_operation = '" . $db->sql_escape($operation) . "'", + 1 + ); + $logId = (int) $db->sql_fetchfield('log_id'); + $db->sql_freeresult($result); + if (!$logId) + { + $logId = (int) $this->context->container->get('log')->add( + 'mod', + $userId, + '127.0.0.1', + $operation, + false, + array_merge(['forum_id' => $forumId, 'topic_id' => $topicId], $data) + ); + } + if (!$logId) + { + throw new RuntimeException("Unable to create development moderator log: $operation"); + } + $this->context->addId('logs', $logId); + } + + private function forumName(int $forumId): string + { + $result = $this->context->db->sql_query_limit( + 'SELECT forum_name FROM ' . FORUMS_TABLE . ' WHERE forum_id = ' . $forumId, + 1 + ); + $name = (string) $this->context->db->sql_fetchfield('forum_name'); + $this->context->db->sql_freeresult($result); + return $name; + } + + private function firstIcon(): int + { + $result = $this->context->db->sql_query_limit('SELECT icons_id FROM ' . ICONS_TABLE . ' ORDER BY icons_id ASC', 1); + $id = (int) $this->context->db->sql_fetchfield('icons_id'); + $this->context->db->sql_freeresult($result); + return $id; + } + + private function nextPoster(): int + { + $id = $this->posters[$this->posterIndex % count($this->posters)]; + $this->posterIndex++; + return $id; + } +} diff --git a/src/QuickInstall/Sandbox/SeedRuntime/DevelopmentSeeder.php b/src/QuickInstall/Sandbox/SeedRuntime/DevelopmentSeeder.php new file mode 100644 index 00000000..2b30ac02 --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedRuntime/DevelopmentSeeder.php @@ -0,0 +1,106 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstallSeed; + +/** Builds and verifies the comprehensive development content plan. */ +class DevelopmentSeeder extends SeedPlan +{ + public function seed(): void + { + $this->context->status('Creating 25 development users and generated avatars...'); + $users = (new UserBuilder($this->context))->seed(); + $this->context->saveManifest(); + $this->context->status('Creating development forum scenarios...'); + $forums = (new ForumBuilder($this->context))->seed(); + $this->context->saveManifest(); + $this->context->status('Creating topic, post, pagination, and poll scenarios...'); + $content = (new ContentBuilder($this->context))->seed($users, $forums); + $this->context->saveManifest(); + $this->context->status('Creating attachments, moderation, UCP, notification, and search states...'); + (new StateBuilder($this->context))->seed($users, $forums, $content); + } + + public function verify(): void + { + $users = $this->context->meta('users', []); + $forums = $this->context->meta('forums', []); + $content = $this->context->meta('content', []); + $messages = $this->context->meta('messages', []); + $ucp = $this->context->meta('ucp', []); + $restrictedGroups = array_filter([ + $this->context->groupId('GUESTS'), + $this->context->groupId('REGISTERED'), + $this->context->groupId('NEWLY_REGISTERED'), + ]); + $checks = [ + '25 users' => $this->existingIdCount(USERS_TABLE, 'user_id', $this->context->ids('users')) === 25, + '25 avatars' => $this->fieldCount(USERS_TABLE, 'user_id', $this->context->ids('users'), "user_avatar <> ''") === 25, + '25 signatures' => $this->fieldCount(USERS_TABLE, 'user_id', $this->context->ids('users'), "user_sig <> ''") === 25, + 'inactive user state' => isset($users['inactive']) && $this->queryCount(USERS_TABLE, 'user_id = ' . (int) $users['inactive'] . ' AND user_type = ' . USER_INACTIVE) === 1, + '4 categories' => $this->existingIdCount(FORUMS_TABLE, 'forum_id', $this->context->ids('categories')) === 4, + '12 forums' => $this->existingIdCount(FORUMS_TABLE, 'forum_id', $this->context->ids('forums')) === 12, + 'password forum uses standard credential' => isset($forums['password']) && $this->forumPasswordMatches((int) $forums['password']), + 'private forum ACL' => isset($forums['private']) && (!$restrictedGroups || $this->queryCount(ACL_GROUPS_TABLE, 'forum_id = ' . (int) $forums['private'] . ' AND ' . $this->context->db->sql_in_set('group_id', $restrictedGroups)) === 0), + 'empty forum state' => isset($forums['empty']) && $this->queryCount(TOPICS_TABLE, 'forum_id = ' . (int) $forums['empty']) === 0, + 'nested subforums' => isset($forums['parent'], $forums['child_a'], $forums['child_b']) && $this->queryCount(FORUMS_TABLE, 'parent_id = ' . (int) $forums['parent'] . ' AND ' . $this->context->db->sql_in_set('forum_id', [(int) $forums['child_a'], (int) $forums['child_b']])) === 2, + '47 topic rows' => $this->existingIdCount(TOPICS_TABLE, 'topic_id', $this->context->ids('topics')) === 47, + '90 posts' => $this->existingIdCount(POSTS_TABLE, 'post_id', $this->context->ids('posts')) === 90, + 'unapproved post' => $this->fieldCount(POSTS_TABLE, 'post_id', $this->context->ids('unapproved_posts'), 'post_visibility = ' . ITEM_UNAPPROVED) === 1, + '2 polls' => $this->fieldCount(TOPICS_TABLE, 'topic_id', $this->context->ids('topics'), "poll_title <> ''") === 2, + '6 poll votes' => isset($content['voted_poll']['topic_id']) && $this->queryCount(POLL_VOTES_TABLE, 'topic_id = ' . (int) $content['voted_poll']['topic_id']) === 6, + '2 attachments' => $this->existingIdCount(ATTACHMENTS_TABLE, 'attach_id', $this->context->ids('attachments')) === 2, + '2 open reports' => $this->existingIdCount(REPORTS_TABLE, 'report_id', $this->context->ids('reports')) === 2, + '9 private messages' => $this->existingIdCount(PRIVMSGS_TABLE, 'msg_id', $this->context->ids('messages')) === 9, + 'read private message' => isset($messages['read']) && $this->queryCount(PRIVMSGS_TO_TABLE, 'msg_id = ' . (int) $messages['read'] . ' AND user_id = ' . $this->context->founderId() . ' AND pm_unread = 0 AND folder_id = ' . PRIVMSGS_INBOX) === 1, + 'sent private message' => isset($messages['sent']) && $this->queryCount(PRIVMSGS_TO_TABLE, 'msg_id = ' . (int) $messages['sent'] . ' AND user_id = ' . $this->context->founderId() . ' AND folder_id = ' . PRIVMSGS_SENTBOX) === 1, + '3 unread notifications' => $this->fieldCount(NOTIFICATIONS_TABLE, 'notification_id', $this->context->ids('notifications'), 'notification_read = 0') === 3, + '2 warnings' => $this->existingIdCount(WARNINGS_TABLE, 'warning_id', $this->context->ids('warnings')) === 2, + '1 active ban' => $this->existingIdCount($this->context->banTable(), 'ban_id', $this->context->ids('bans')) === 1, + '1 saved draft' => $this->existingIdCount(DRAFTS_TABLE, 'draft_id', $this->context->ids('drafts')) === 1, + 'topic bookmark' => isset($ucp['topic_id'], $ucp['user_id']) && $this->queryCount(BOOKMARKS_TABLE, 'topic_id = ' . (int) $ucp['topic_id'] . ' AND user_id = ' . (int) $ucp['user_id']) === 1, + 'topic watch' => isset($ucp['topic_id'], $ucp['user_id']) && $this->queryCount(TOPICS_WATCH_TABLE, 'topic_id = ' . (int) $ucp['topic_id'] . ' AND user_id = ' . (int) $ucp['user_id']) === 1, + 'forum watch' => isset($ucp['forum_id'], $ucp['user_id']) && $this->queryCount(FORUMS_WATCH_TABLE, 'forum_id = ' . (int) $ucp['forum_id'] . ' AND user_id = ' . (int) $ucp['user_id']) === 1, + 'generated files' => $this->existingFileCount() === 28, + 'generated storage files' => !$this->context->usesStorage() + || ($this->context->storageFileCount() === 27 && $this->context->storageFilesExist()), + 'search backend indexed' => (string) $this->context->meta('search_backend', '') !== '', + 'locked topic' => $this->topicStateCount('topic_status', ITEM_LOCKED) >= 1, + 'moved redirect' => $this->topicStateCount('topic_status', ITEM_MOVED) === 1, + 'sticky topic' => $this->topicStateCount('topic_type', POST_STICKY) >= 1, + 'announcement topic' => $this->topicStateCount('topic_type', POST_ANNOUNCE) >= 1, + 'global announcement' => $this->topicStateCount('topic_type', POST_GLOBAL) >= 1, + ]; + $this->assertChecks($checks, 'Development seed verification failed: '); + echo "Seeded development preset {$this->context->seed}: 25 users, 12 forums, 47 topic rows, 90 posts.\n"; + } + + private function topicStateCount(string $column, int $value): int + { + return $this->fieldCount(TOPICS_TABLE, 'topic_id', $this->context->ids('topics'), "$column = $value"); + } + + private function forumPasswordMatches(int $forumId): bool + { + $result = $this->context->db->sql_query_limit( + 'SELECT forum_password FROM ' . FORUMS_TABLE . ' WHERE forum_id = ' . $forumId, + 1 + ); + $password = (string) $this->context->db->sql_fetchfield('forum_password'); + $this->context->db->sql_freeresult($result); + return $password !== '' + && $this->context->container->get('passwords.manager')->check(SeedContext::PASSWORD, $password); + } + + private function existingFileCount(): int + { + return count(array_filter($this->context->manifest['files'] ?? [], 'is_file')); + } +} diff --git a/src/QuickInstall/Sandbox/SeedRuntime/ForumBuilder.php b/src/QuickInstall/Sandbox/SeedRuntime/ForumBuilder.php new file mode 100644 index 00000000..349312ed --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedRuntime/ForumBuilder.php @@ -0,0 +1,303 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstallSeed; + +use acp_forums; +use RuntimeException; + +class ForumBuilder extends BaseBuilder +{ + public function seed(): array + { + $db = $this->context->db; + if (!class_exists('acp_forums')) + { + require_once $this->context->root . 'includes/acp/acp_forums.' . $this->context->phpEx; + } + + $defaultForum = $this->firstForum(FORUM_POST); + if (!$defaultForum) + { + throw new RuntimeException('Development preset requires an existing postable forum.'); + } + + $acp = new acp_forums(); + $forums = ['news' => $defaultForum]; + $forums['lobby'] = $this->create([ + 'parent_id' => 0, + 'forum_type' => FORUM_POST, + 'forum_name' => $this->context->prefix . 'Lobby forum', + 'forum_desc' => 'A regular forum with topics, replies, rules, and a mix of post types.', + ], $acp, $defaultForum, 'forums'); + + $categories = [ + 'Forum states' => [ + 'read' => ['Read forum', 'This forum is marked as read for the board founder.'], + 'unread' => ['Unread forum', 'This forum contains unread posts for the board founder.'], + ], + 'Nested forums' => [ + 'parent' => ['Parent forum', 'This forum contains two child forums.'], + ], + 'Locked forums' => [ + 'locked_forum' => ['Locked forum', 'You can read this forum, but you cannot start topics or post replies.', 'status' => ITEM_LOCKED], + ], + 'Special forums' => [ + 'link' => ['Link forum', 'Opening this forum takes you to phpBB.com.', 'type' => FORUM_LINK, 'link' => 'https://www.phpbb.com/'], + 'password' => ['Password-protected forum', 'This forum requires a password. Password: password', 'password' => SeedContext::PASSWORD], + 'private' => ['Private forum', 'Only members with the right permissions can see this forum.'], + 'empty' => ['Empty forum', 'This forum intentionally contains no topics.'], + 'icon' => ['Forum with icon', 'This forum displays a generated image beside its name.', 'image' => $this->forumIcon()], + ], + ]; + $categoryDescriptions = [ + 'Forum states' => 'Forums showing read and unread states.', + 'Nested forums' => 'A parent forum with two child forums.', + 'Locked forums' => 'A forum where new topics and replies are disabled.', + 'Special forums' => 'Forums with links, passwords, permissions, icons, or no topics.', + ]; + + foreach ($categories as $categoryName => $definitions) + { + $categoryId = $this->create([ + 'parent_id' => 0, + 'forum_type' => FORUM_CAT, + 'forum_name' => $this->context->prefix . $categoryName, + 'forum_desc' => $categoryDescriptions[$categoryName], + ], $acp, $defaultForum, 'categories'); + + foreach ($definitions as $key => $definition) + { + $data = [ + 'parent_id' => $categoryId, + 'forum_type' => $definition['type'] ?? FORUM_POST, + 'forum_name' => $this->context->prefix . $definition[0], + 'forum_desc' => $definition[1], + ]; + if (isset($definition['status'])) + { + $data['forum_status'] = $definition['status']; + } + if (isset($definition['link'])) + { + $data['forum_link'] = $definition['link']; + } + if (isset($definition['password'])) + { + $data['forum_password'] = $definition['password']; + $data['forum_password_confirm'] = $definition['password']; + } + if (isset($definition['image'])) + { + $data['forum_image'] = $definition['image']; + } + $forums[$key] = $this->create($data, $acp, $defaultForum, 'forums'); + } + } + + $forums['child_a'] = $this->create([ + 'parent_id' => $forums['parent'], + 'forum_type' => FORUM_POST, + 'forum_name' => $this->context->prefix . 'Child subforum A', + 'forum_desc' => 'The first child forum inside the parent forum.', + ], $acp, $defaultForum, 'forums'); + $forums['child_b'] = $this->create([ + 'parent_id' => $forums['parent'], + 'forum_type' => FORUM_POST, + 'forum_name' => $this->context->prefix . 'Child subforum B', + 'forum_desc' => 'The second child forum inside the parent forum.', + ], $acp, $defaultForum, 'forums'); + + $this->makePrivate($forums['private']); + $this->addRules([$forums['lobby'], $forums['read'], $forums['unread']]); + $this->markRead($forums['read']); + $this->context->setMeta('forums', $forums); + $this->context->auth->acl_clear_prefetch(); + return $forums; + } + + public function seedVolume(int $categoryCount, int $forumsPerCategory): array + { + if (!class_exists('acp_forums')) + { + require_once $this->context->root . 'includes/acp/acp_forums.' . $this->context->phpEx; + } + $permissionSource = $this->firstForum(FORUM_POST); + if (!$permissionSource) + { + throw new RuntimeException('Seed preset requires an existing postable forum.'); + } + + $acp = new acp_forums(); + $forums = []; + for ($category = 1; $category <= $categoryCount; $category++) + { + $categoryId = $this->create([ + 'parent_id' => 0, + 'forum_type' => FORUM_CAT, + 'forum_name' => sprintf('%sCategory %02d', $this->context->prefix, $category), + 'forum_desc' => sprintf('Category %d generated by the %s preset.', $category, $this->context->preset), + ], $acp, $permissionSource, 'categories'); + + for ($forum = 1; $forum <= $forumsPerCategory; $forum++) + { + $forums[] = $this->create([ + 'parent_id' => $categoryId, + 'forum_type' => FORUM_POST, + 'forum_name' => sprintf('%sForum %02d-%02d', $this->context->prefix, $category, $forum), + 'forum_desc' => sprintf('Forum %d in category %d generated by the %s preset.', $forum, $category, $this->context->preset), + ], $acp, $permissionSource, 'forums'); + } + } + $this->context->auth->acl_clear_prefetch(); + return $forums; + } + + private function firstForum(int $type): int + { + $result = $this->context->db->sql_query_limit( + 'SELECT forum_id FROM ' . FORUMS_TABLE . ' WHERE forum_type = ' . $type . ' ORDER BY forum_id ASC', + 1 + ); + $id = (int) $this->context->db->sql_fetchfield('forum_id'); + $this->context->db->sql_freeresult($result); + return $id; + } + + private function create(array $data, acp_forums $acp, int $permissionSource, string $manifestType): int + { + $db = $this->context->db; + $result = $db->sql_query_limit( + 'SELECT forum_id FROM ' . FORUMS_TABLE . " + WHERE forum_name = '" . $db->sql_escape($data['forum_name']) . "'", + 1 + ); + $id = (int) $db->sql_fetchfield('forum_id'); + $db->sql_freeresult($result); + if (!$id) + { + $forumData = array_merge([ + 'parent_id' => 0, + 'forum_type' => FORUM_POST, + 'forum_status' => ITEM_UNLOCKED, + 'forum_parents' => '', + 'forum_options' => 0, + 'forum_name' => '', + 'forum_link' => '', + 'forum_link_track' => false, + 'forum_desc' => '', + 'forum_desc_uid' => '', + 'forum_desc_options' => 7, + 'forum_desc_bitfield' => '', + 'forum_rules' => '', + 'forum_rules_uid' => '', + 'forum_rules_options' => 7, + 'forum_rules_bitfield' => '', + 'forum_rules_link' => '', + 'forum_image' => '', + 'forum_style' => 0, + 'forum_password' => '', + 'forum_password_confirm' => '', + 'display_subforum_list' => true, + 'display_on_index' => true, + 'forum_topics_per_page' => 0, + 'enable_indexing' => true, + 'enable_icons' => true, + 'enable_prune' => false, + 'enable_post_review' => true, + 'enable_quick_reply' => true, + 'prune_days' => 7, + 'prune_viewed' => 7, + 'prune_freq' => 1, + 'prune_old_polls' => false, + 'prune_announce' => false, + 'prune_sticky' => false, + 'forum_password_unset' => false, + 'show_active' => 1, + ], $data); + generate_text_for_storage( + $forumData['forum_desc'], + $forumData['forum_desc_uid'], + $forumData['forum_desc_bitfield'], + $forumData['forum_desc_options'], + false, + false, + false + ); + $errors = $acp->update_forum_data($forumData); + if ($errors) + { + throw new RuntimeException('Unable to create seed forum: ' . implode('; ', $errors)); + } + $id = (int) $forumData['forum_id']; + copy_forum_permissions($permissionSource, [$id]); + } + $this->context->addId($manifestType, $id); + return $id; + } + + private function forumIcon(): string + { + $relative = 'images/qi-development-' . $this->context->seed . '-forum.png'; + $path = $this->context->root . $relative; + if (file_put_contents($path, AssetFactory::png(96, 96, $this->context->seed + 9000), LOCK_EX) === false) + { + throw new RuntimeException("Unable to write generated forum icon: $path"); + } + $this->context->addFile($path); + return $relative; + } + + private function makePrivate(int $forumId): void + { + $groupIds = array_filter([ + $this->context->groupId('GUESTS'), + $this->context->groupId('REGISTERED'), + $this->context->groupId('NEWLY_REGISTERED'), + ]); + if ($groupIds) + { + $this->context->db->sql_query('DELETE FROM ' . ACL_GROUPS_TABLE . ' + WHERE forum_id = ' . $forumId . ' + AND ' . $this->context->db->sql_in_set('group_id', $groupIds)); + } + } + + private function addRules(array $forumIds): void + { + $db = $this->context->db; + $text = '[b]Sample forum rules[/b] Be kind, stay on topic, and read the [url=https://www.phpbb.com/rules/]full rules[/url] before posting.'; + $uid = $bitfield = ''; + $options = 7; + generate_text_for_storage($text, $uid, $bitfield, $options, true, true, true); + foreach ($forumIds as $forumId) + { + $db->sql_query('UPDATE ' . FORUMS_TABLE . " + SET forum_rules = '" . $db->sql_escape($text) . "', + forum_rules_uid = '" . $db->sql_escape($uid) . "', + forum_rules_bitfield = '" . $db->sql_escape($bitfield) . "', + forum_rules_options = $options + WHERE forum_id = " . (int) $forumId); + } + } + + private function markRead(int $forumId): void + { + $db = $this->context->db; + $founderId = $this->context->founderId(); + $db->sql_query('DELETE FROM ' . FORUMS_TRACK_TABLE . ' + WHERE user_id = ' . $founderId . ' AND forum_id = ' . $forumId); + $db->sql_query('INSERT INTO ' . FORUMS_TRACK_TABLE . ' ' . $db->sql_build_array('INSERT', [ + 'user_id' => $founderId, + 'forum_id' => $forumId, + 'mark_time' => time() + 3600, + ])); + } +} diff --git a/src/QuickInstall/Sandbox/SeedRuntime/SeedContext.php b/src/QuickInstall/Sandbox/SeedRuntime/SeedContext.php new file mode 100644 index 00000000..7ec13f02 --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedRuntime/SeedContext.php @@ -0,0 +1,502 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * @noinspection PhpMissingFieldTypeInspection + */ + +namespace QuickInstallSeed; + +use RuntimeException; +use ZipArchive; + +class SeedContext +{ + public const PASSWORD = 'password'; + + /** @var \phpbb\db\driver\driver_interface */ + public $db; + + /** @var \phpbb\user */ + public $user; + + /** @var \phpbb\auth\auth */ + public $auth; + + /** @var \phpbb\config\config */ + public $config; + + /** @var \Symfony\Component\DependencyInjection\ContainerInterface */ + public $container; + + /** @var string */ + public $root; + + /** @var string */ + public $phpEx; + + /** @var string */ + public $preset; + + /** @var int */ + public $seed; + + /** @var string */ + public $prefix; + + /** @var array */ + public $manifest; + + /** @var array */ + private $originalUser; + + public function __construct($db, $user, $auth, $config, $container, string $root, string $phpEx, string $preset, int $seed) + { + $this->db = $db; + $this->user = $user; + $this->auth = $auth; + $this->config = $config; + $this->container = $container; + $this->root = rtrim(str_replace('\\', '/', $root), '/') . '/'; + $this->phpEx = $phpEx; + $this->preset = $preset; + $this->seed = $seed; + $this->prefix = $preset === 'development' + ? sprintf('[QI %d] ', $seed) + : sprintf('[QI %s %d] ', $preset, $seed); + $this->resetManifest(); + $this->originalUser = $user->data; + } + + public function resetManifest(): void + { + $this->manifest = [ + 'version' => 3, + 'preset' => $this->preset, + 'seed' => $this->seed, + 'created_at' => gmdate('c'), + 'ids' => [], + 'files' => [], + 'storage_files' => [], + 'meta' => [], + ]; + } + + public function status(string $message): void + { + echo $message . "\n"; + flush(); + } + + public function manifestPath(): string + { + $preset = preg_replace('/[^A-Za-z0-9._-]/', '_', $this->preset); + return $this->root . 'store/qi-' . $preset . '-seed-' . $this->seed . '.json'; + } + + public function loadManifest(): bool + { + $path = $this->manifestPath(); + if (!is_file($path)) + { + return false; + } + + $data = json_decode((string) file_get_contents($path), true); + if (!is_array($data) || ($data['preset'] ?? '') !== $this->preset || (int) ($data['seed'] ?? 0) !== $this->seed) + { + throw new RuntimeException("Invalid seed manifest: $path"); + } + $data['version'] = 3; + $data['storage_files'] = $data['storage_files'] ?? []; + $this->manifest = $data; + return true; + } + + public function saveManifest(): void + { + $path = $this->manifestPath(); + $directory = dirname($path); + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) + { + throw new RuntimeException("Unable to create seed manifest directory: $directory"); + } + + $json = json_encode($this->manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false || file_put_contents($path, $json . "\n", LOCK_EX) === false) + { + throw new RuntimeException("Unable to write seed manifest: $path"); + } + } + + public function addId(string $type, int $id): void + { + if ($id < 1) + { + return; + } + if (!isset($this->manifest['ids'][$type])) + { + $this->manifest['ids'][$type] = []; + } + $this->manifest['ids'][$type][] = $id; + $this->manifest['ids'][$type] = array_values(array_unique(array_map('intval', $this->manifest['ids'][$type]))); + } + + public function ids(string $type): array + { + return array_values(array_unique(array_map('intval', $this->manifest['ids'][$type] ?? []))); + } + + public function addFile(string $path): void + { + $path = str_replace('\\', '/', $path); + /** @noinspection PhpStrFunctionsInspection */ + /** @noinspection StrStartsWithCanBeUsedInspection */ + if (strpos($path, $this->root) !== 0) + { + throw new RuntimeException("Refusing to register seed file outside phpBB root: $path"); + } + $this->manifest['files'][] = $path; + $this->manifest['files'] = array_values(array_unique($this->manifest['files'])); + } + + public function writeFile(string $storageName, string $relativePath, string $absolutePath, string $content): void + { + $serviceName = 'storage.' . $storageName; + if ($this->container->has($serviceName)) + { + $storage = $this->container->get($serviceName); + if (!$storage->exists($relativePath)) + { + $stream = fopen('php://temp', 'w+b'); + /** @noinspection NotOptimalIfConditionsInspection */ + if ($stream === false || fwrite($stream, $content) !== strlen($content) || rewind($stream) === false) + { + if (is_resource($stream)) + { + fclose($stream); + } + throw new RuntimeException("Unable to prepare generated storage file: $relativePath"); + } + try + { + $storage->write($relativePath, $stream); + } + finally + { + fclose($stream); + } + } + $this->addStorageFile($storageName, $relativePath); + } + else if (file_put_contents($absolutePath, $content, LOCK_EX) === false) + { + throw new RuntimeException("Unable to write generated file: $absolutePath"); + } + + $this->addFile($absolutePath); + } + + public function storageDirectory(string $storageName, string $fallback): string + { + return trim((string) ($this->config['storage\\' . $storageName . '\\config\\path'] ?? $fallback), '/'); + } + + public function reconcileStorageFiles(): void + { + $paths = [ + 'attachment' => $this->storageDirectory('attachment', (string) ($this->config['upload_path'] ?? 'files')), + 'avatar' => $this->storageDirectory('avatar', (string) ($this->config['avatar_path'] ?? 'images/avatars/upload')), + ]; + foreach ($this->manifest['files'] ?? [] as $absolutePath) + { + $absolutePath = str_replace('\\', '/', (string) $absolutePath); + foreach ($paths as $storageName => $directory) + { + $prefix = $this->root . $directory . '/'; + /** @noinspection PhpStrFunctionsInspection */ + /** @noinspection StrStartsWithCanBeUsedInspection */ + if (strpos($absolutePath, $prefix) !== 0) + { + continue; + } + $this->trackStorageFile($storageName, substr($absolutePath, strlen($prefix)), $absolutePath); + } + } + } + + public function storageFilesExist(): bool + { + foreach ($this->manifest['storage_files'] ?? [] as $file) + { + $storageName = (string) ($file['storage'] ?? ''); + $relativePath = (string) ($file['path'] ?? ''); + $serviceName = 'storage.' . $storageName; + if (!$this->container->has($serviceName) + || !$this->container->get($serviceName)->exists($relativePath) + || ($this->isLocalStorage($storageName) && !is_file($this->storagePath($storageName, $relativePath)))) + { + return false; + } + } + return true; + } + + public function storageFileCount(): int + { + return count($this->manifest['storage_files'] ?? []); + } + + public function usesStorage(): bool + { + return $this->container->has('storage.attachment') || $this->container->has('storage.avatar'); + } + + private function addStorageFile(string $storageName, string $relativePath): void + { + $file = [ + 'storage' => $storageName, + 'path' => $relativePath, + ]; + foreach ($this->manifest['storage_files'] ?? [] as $registered) + { + if ($registered === $file) + { + return; + } + } + $this->manifest['storage_files'][] = $file; + } + + private function trackStorageFile(string $storageName, string $relativePath, string $absolutePath): void + { + $serviceName = 'storage.' . $storageName; + if (!$this->container->has($serviceName)) + { + return; + } + $storage = $this->container->get($serviceName); + if (!$storage->exists($relativePath)) + { + if (!is_file($absolutePath) || !$this->container->has('storage.file_tracker')) + { + throw new RuntimeException("Unable to register generated storage file: $relativePath"); + } + $this->container->get('storage.file_tracker')->track_file($storageName, $relativePath, (int) filesize($absolutePath)); + } + $this->addStorageFile($storageName, $relativePath); + } + + private function isLocalStorage(string $storageName): bool + { + return (string) ($this->config['storage\\' . $storageName . '\\provider'] ?? '') === 'phpbb\\storage\\provider\\local'; + } + + private function storagePath(string $storageName, string $relativePath): string + { + $directory = trim((string) ($this->config['storage\\' . $storageName . '\\config\\path'] ?? ''), '/'); + return $this->root . ($directory !== '' ? $directory . '/' : '') . $relativePath; + } + + public function setMeta(string $name, $value): void + { + $this->manifest['meta'][$name] = $value; + } + + public function meta(string $name, $default = null) + { + return $this->manifest['meta'][$name] ?? $default; + } + + public function groupId(string $name): int + { + $sql = 'SELECT group_id FROM ' . GROUPS_TABLE . " + WHERE group_name = '" . $this->db->sql_escape($name) . "'"; + $result = $this->db->sql_query_limit($sql, 1); + $id = (int) $this->db->sql_fetchfield('group_id'); + $this->db->sql_freeresult($result); + return $id; + } + + public function founderId(): int + { + $result = $this->db->sql_query_limit( + 'SELECT user_id FROM ' . USERS_TABLE . ' WHERE user_type = ' . USER_FOUNDER . ' ORDER BY user_id ASC', + 1 + ); + $id = (int) $this->db->sql_fetchfield('user_id'); + $this->db->sql_freeresult($result); + return $id ?: 2; + } + + public function banTable(): string + { + if (defined('BANS_TABLE')) + { + return constant('BANS_TABLE'); + } + if (defined('BANLIST_TABLE')) + { + return constant('BANLIST_TABLE'); + } + throw new RuntimeException('Unable to resolve the phpBB bans table.'); + } + + public function usesModernBans(): bool + { + return defined('BANS_TABLE'); + } + + public function switchUser(int $userId): void + { + $result = $this->db->sql_query_limit('SELECT * FROM ' . USERS_TABLE . ' WHERE user_id = ' . $userId, 1); + $row = $this->db->sql_fetchrow($result); + $this->db->sql_freeresult($result); + if (!$row) + { + throw new RuntimeException("Unable to switch seed user context: $userId"); + } + + $this->user->data = array_merge($this->user->data, $row); + $this->user->data['is_registered'] = true; + $this->auth->acl($this->user->data); + } + + public function restoreUser(): void + { + $this->user->data = $this->originalUser; + $this->auth->acl($this->user->data); + } + + public function deleteRegisteredFiles(): void + { + foreach (array_reverse($this->manifest['storage_files'] ?? []) as $file) + { + $serviceName = 'storage.' . ($file['storage'] ?? ''); + $relativePath = (string) ($file['path'] ?? ''); + if ($relativePath !== '' && $this->container->has($serviceName)) + { + $storage = $this->container->get($serviceName); + if ($storage->exists($relativePath)) + { + $storage->delete($relativePath); + } + } + } + foreach (array_reverse($this->manifest['files'] ?? []) as $path) + { + $path = str_replace('\\', '/', (string) $path); + /** @noinspection PhpStrFunctionsInspection */ + /** @noinspection StrStartsWithCanBeUsedInspection */ + if (strpos($path, $this->root) !== 0) + { + continue; + } + if (is_file($path) && !unlink($path)) + { + throw new RuntimeException("Unable to delete seed file: $path"); + } + } + } +} + +abstract class BaseBuilder +{ + /** @var SeedContext */ + protected $context; + + public function __construct(SeedContext $context) + { + $this->context = $context; + } + + /** Returns the last inserted ID across supported phpBB DBAL versions. */ + protected function lastInsertedId(): int + { + if (method_exists($this->context->db, 'sql_last_inserted_id')) + { + return (int) $this->context->db->sql_last_inserted_id(); + } + + /** @noinspection PhpDeprecationInspection */ + return (int) $this->context->db->sql_nextid(); + } +} + +class AssetFactory +{ + public static function png(int $width, int $height, int $seed): string + { + $raw = ''; + $r1 = 35 + (($seed * 37) % 190); + $g1 = 35 + (($seed * 59) % 190); + $b1 = 35 + (($seed * 71) % 190); + $r2 = 255 - $r1; + $g2 = 255 - $g1; + $b2 = 255 - $b1; + + for ($y = 0; $y < $height; $y++) + { + $raw .= "\x00"; + for ($x = 0; $x < $width; $x++) + { + $cellX = (int) floor($x * 5 / max(1, $width)); + $cellY = (int) floor($y * 5 / max(1, $height)); + $mirrorX = min($cellX, 4 - $cellX); + $foreground = (($mirrorX * 11 + $cellY * 7 + $seed) % 3) !== 0; + $raw .= chr($foreground ? $r1 : $r2); + $raw .= chr($foreground ? $g1 : $g2); + $raw .= chr($foreground ? $b1 : $b2); + } + } + + $header = pack('NNCCCCC', $width, $height, 8, 2, 0, 0, 0); + return "\x89PNG\r\n\x1a\n" + . self::chunk('IHDR', $header) + . self::chunk('IDAT', gzcompress($raw, 9)) + . self::chunk('IEND', ''); + } + + public static function zip(int $seed): string + { + if (!class_exists('ZipArchive')) + { + throw new RuntimeException('Development attachment generation requires ZipArchive.'); + } + + $path = tempnam(sys_get_temp_dir(), 'qi-dev-'); + if ($path === false) + { + throw new RuntimeException('Unable to create temporary development ZIP.'); + } + + $zip = new ZipArchive(); + if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) + { + unlink($path); + throw new RuntimeException('Unable to create development ZIP.'); + } + $zip->addFromString('README.txt', "QuickInstall sample archive\n\nThis ZIP file is attached by the development seed.\nSeed: $seed\n"); + $zip->addFromString('sample/example-data.txt', "This is a plain-text file inside the sample archive.\n"); + $zip->addFromString('unicode/café.txt', "This filename includes a Unicode character.\n"); + $zip->close(); + + $content = file_get_contents($path); + unlink($path); + if ($content === false) + { + throw new RuntimeException('Unable to read generated development ZIP.'); + } + return $content; + } + + private static function chunk(string $type, string $data): string + { + return pack('N', strlen($data)) . $type . $data . pack('N', crc32($type . $data)); + } +} diff --git a/src/QuickInstall/Sandbox/SeedRuntime/SeedPlan.php b/src/QuickInstall/Sandbox/SeedRuntime/SeedPlan.php new file mode 100644 index 00000000..95d7a05b --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedRuntime/SeedPlan.php @@ -0,0 +1,84 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstallSeed; + +use RuntimeException; + +/** Provides shared context and verification helpers for preset plans. */ +abstract class SeedPlan +{ + /** + * @var SeedContext + * @noinspection PhpMissingFieldTypeInspection + */ + protected $context; + + public function __construct(SeedContext $context) + { + $this->context = $context; + } + + protected function existingIdCount(string $table, string $column, array $ids): int + { + if (!$ids) + { + return 0; + } + $result = $this->context->db->sql_query("SELECT COUNT($column) AS total FROM $table WHERE " + . $this->context->db->sql_in_set($column, $ids)); + $count = (int) $this->context->db->sql_fetchfield('total'); + $this->context->db->sql_freeresult($result); + return $count; + } + + protected function fieldCount(string $table, string $column, array $ids, string $condition): int + { + if (!$ids) + { + return 0; + } + $result = $this->context->db->sql_query("SELECT COUNT($column) AS total FROM $table WHERE " + . $this->context->db->sql_in_set($column, $ids) . " AND $condition"); + $count = (int) $this->context->db->sql_fetchfield('total'); + $this->context->db->sql_freeresult($result); + return $count; + } + + protected function queryCount(string $table, string $where): int + { + $result = $this->context->db->sql_query("SELECT COUNT(*) AS total FROM $table WHERE $where"); + $count = (int) $this->context->db->sql_fetchfield('total'); + $this->context->db->sql_freeresult($result); + return $count; + } + + protected function assertChecks(array $checks, string $failureMessage): void + { + $failed = []; + foreach ($checks as $label => $passed) + { + echo ($passed ? '[OK] ' : '[FAIL] ') . $label . "\n"; + if (!$passed) + { + $failed[] = $label; + } + } + if ($failed) + { + throw new RuntimeException($failureMessage . implode(', ', $failed)); + } + } + + protected function plural(int $count, string $singular, string $plural): string + { + return $count === 1 ? $singular : $plural; + } +} diff --git a/src/QuickInstall/Sandbox/SeedRuntime/Seeder.php b/src/QuickInstall/Sandbox/SeedRuntime/Seeder.php new file mode 100644 index 00000000..3302e419 --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedRuntime/Seeder.php @@ -0,0 +1,280 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstallSeed; + +use RuntimeException; + +/** Coordinates every preset through one manifest, reset, and finalization lifecycle. */ +class Seeder +{ + /** + * @var SeedContext + * @noinspection PhpMissingFieldTypeInspection + */ + private $context; + + public function __construct(SeedContext $context) + { + $this->context = $context; + } + + public function run(string $action): void + { + if (!in_array($action, ['seed', 'reset', 'replace'], true)) + { + throw new RuntimeException("Unknown seed action: $action"); + } + + $strategy = $this->strategy(); + $hasManifest = $this->context->loadManifest(); + if ($hasManifest) + { + $this->context->reconcileStorageFiles(); + $this->context->saveManifest(); + } + + if ($action === 'reset' || $action === 'replace') + { + $this->context->status("Resetting {$this->context->preset} seed data..."); + if ($hasManifest) + { + $this->reset(); + } + if ($action === 'reset') + { + echo "Reset {$this->context->preset} seed {$this->context->seed}.\n"; + return; + } + $this->context->resetManifest(); + $hasManifest = false; + } + + if ($hasManifest) + { + $this->context->status("{$this->context->preset} seed already exists; verifying data..."); + $strategy->verify(); + return; + } + + $this->context->setMeta('founder_state', $this->founderState()); + $strategy->seed(); + $this->finalize($this->context->preset === 'development'); + $this->context->saveManifest(); + $strategy->verify(); + } + + /** + * @return DevelopmentSeeder|VolumeSeeder + * @noinspection PhpMissingReturnTypeInspection + */ + private function strategy() + { + if ($this->context->preset === 'development') + { + return new DevelopmentSeeder($this->context); + } + return new VolumeSeeder($this->context); + } + + private function reset(): void + { + $db = $this->context->db; + $this->deleteByIds(NOTIFICATIONS_TABLE, 'notification_id', $this->context->ids('notifications')); + $this->deleteByIds(REPORTS_TABLE, 'report_id', $this->context->ids('reports')); + $this->deleteByIds(DRAFTS_TABLE, 'draft_id', $this->context->ids('drafts')); + $this->deleteByIds(ATTACHMENTS_TABLE, 'attach_id', $this->context->ids('attachments')); + $this->deleteByIds(LOG_TABLE, 'log_id', $this->context->ids('logs')); + + $messageIds = $this->context->ids('messages'); + $this->deleteByIds(PRIVMSGS_TO_TABLE, 'msg_id', $messageIds); + $this->deleteByIds(PRIVMSGS_TABLE, 'msg_id', $messageIds); + + $topicIds = $this->context->ids('topics'); + $forumIds = array_values(array_unique(array_merge( + $this->context->ids('forums'), + $this->context->ids('categories') + ))); + $founderId = $this->context->founderId(); + if ($topicIds) + { + $db->sql_query('DELETE FROM ' . BOOKMARKS_TABLE . ' + WHERE user_id = ' . $founderId . ' + AND ' . $db->sql_in_set('topic_id', $topicIds)); + $db->sql_query('DELETE FROM ' . TOPICS_WATCH_TABLE . ' + WHERE user_id = ' . $founderId . ' + AND ' . $db->sql_in_set('topic_id', $topicIds)); + delete_topics('topic_id', $topicIds, true, true, true); + } + if ($forumIds) + { + $db->sql_query('DELETE FROM ' . FORUMS_WATCH_TABLE . ' + WHERE user_id = ' . $founderId . ' + AND ' . $db->sql_in_set('forum_id', $forumIds)); + foreach ([ACL_GROUPS_TABLE, ACL_USERS_TABLE, FORUMS_TRACK_TABLE, FORUMS_ACCESS_TABLE] as $table) + { + $db->sql_query("DELETE FROM $table WHERE " . $db->sql_in_set('forum_id', $forumIds)); + } + $db->sql_query('DELETE FROM ' . FORUMS_TABLE . ' WHERE ' . $db->sql_in_set('forum_id', $forumIds)); + $newId = 1; + recalc_nested_sets($newId, 'forum_id', FORUMS_TABLE); + } + + $this->deleteByIds(WARNINGS_TABLE, 'warning_id', $this->context->ids('warnings')); + $banIds = $this->context->ids('bans'); + if ($banIds) + { + $this->deleteByIds($this->context->banTable(), 'ban_id', $banIds); + } + $userIds = $this->context->ids('users'); + if ($userIds) + { + user_delete('remove', $userIds); + } + + $this->context->deleteRegisteredFiles(); + $this->restoreFounderState(); + $this->syncPrivateMessageCounts(); + $this->finalize(false); + $path = $this->context->manifestPath(); + if (is_file($path) && !unlink($path)) + { + throw new RuntimeException("Unable to delete seed manifest: $path"); + } + } + + private function deleteByIds(string $table, string $column, array $ids): void + { + if ($ids) + { + $this->context->db->sql_query("DELETE FROM $table WHERE " . $this->context->db->sql_in_set($column, $ids)); + } + } + + private function finalize(bool $markUnread): void + { + $db = $this->context->db; + sync('forum', '', '', false, true); + sync('topic'); + $this->syncUserPostCounts(); + + if ($markUnread) + { + $founderId = $this->context->founderId(); + $db->sql_query('UPDATE ' . USERS_TABLE . ' + SET user_lastmark = ' . (time() - 86400) . ', + user_lastvisit = ' . (time() - 86400) . ' + WHERE user_id = ' . $founderId); + } + $this->syncConfigCounts(); + $this->context->auth->acl_clear_prefetch(); + $GLOBALS['cache']->purge(); + } + + private function syncUserPostCounts(): void + { + $db = $this->context->db; + $db->sql_query('UPDATE ' . USERS_TABLE . ' SET user_posts = 0'); + $result = $db->sql_query('SELECT poster_id, COUNT(post_id) AS post_count + FROM ' . POSTS_TABLE . ' + WHERE poster_id > 1 + AND post_postcount = 1 + AND post_visibility = ' . ITEM_APPROVED . ' + GROUP BY poster_id'); + while ($row = $db->sql_fetchrow($result)) + { + $db->sql_query('UPDATE ' . USERS_TABLE . ' + SET user_posts = ' . (int) $row['post_count'] . ' + WHERE user_id = ' . (int) $row['poster_id']); + } + $db->sql_freeresult($result); + } + + private function syncPrivateMessageCounts(): void + { + $db = $this->context->db; + $userIds = array_values(array_unique(array_merge( + [$this->context->founderId()], + $this->context->ids('users') + ))); + foreach ($userIds as $userId) + { + $result = $db->sql_query('SELECT + SUM(CASE WHEN pm_new = 1 THEN 1 ELSE 0 END) AS new_count, + SUM(CASE WHEN pm_unread = 1 THEN 1 ELSE 0 END) AS unread_count + FROM ' . PRIVMSGS_TO_TABLE . ' + WHERE user_id = ' . $userId); + $row = $db->sql_fetchrow($result); + $db->sql_freeresult($result); + $db->sql_query('UPDATE ' . USERS_TABLE . ' + SET user_new_privmsg = ' . (int) ($row['new_count'] ?? 0) . ', + user_unread_privmsg = ' . (int) ($row['unread_count'] ?? 0) . ' + WHERE user_id = ' . $userId); + } + } + + private function syncConfigCounts(): void + { + $db = $this->context->db; + $config = $this->context->config; + $result = $db->sql_query('SELECT COUNT(user_id) AS total FROM ' . USERS_TABLE . ' WHERE user_type <> ' . USER_IGNORE); + $config->set('num_users', (int) $db->sql_fetchfield('total')); + $db->sql_freeresult($result); + $result = $db->sql_query('SELECT COUNT(post_id) AS total FROM ' . POSTS_TABLE . ' WHERE post_visibility = ' . ITEM_APPROVED); + $config->set('num_posts', (int) $db->sql_fetchfield('total')); + $db->sql_freeresult($result); + $result = $db->sql_query('SELECT COUNT(topic_id) AS total FROM ' . TOPICS_TABLE . ' WHERE topic_visibility = ' . ITEM_APPROVED); + $config->set('num_topics', (int) $db->sql_fetchfield('total')); + $db->sql_freeresult($result); + $result = $db->sql_query_limit( + 'SELECT user_id, username, user_colour FROM ' . USERS_TABLE . ' + WHERE user_type <> ' . USER_IGNORE . ' + ORDER BY user_id DESC', + 1 + ); + $newest = $db->sql_fetchrow($result); + $db->sql_freeresult($result); + if ($newest) + { + $config->set('newest_user_id', (int) $newest['user_id']); + $config->set('newest_username', $newest['username']); + $config->set('newest_user_colour', $newest['user_colour']); + } + } + + private function founderState(): array + { + $founderId = $this->context->founderId(); + $result = $this->context->db->sql_query_limit( + 'SELECT user_lastmark, user_lastvisit FROM ' . USERS_TABLE . ' WHERE user_id = ' . $founderId, + 1 + ); + $row = $this->context->db->sql_fetchrow($result) ?: []; + $this->context->db->sql_freeresult($result); + return [ + 'user_id' => $founderId, + 'user_lastmark' => (int) ($row['user_lastmark'] ?? 0), + 'user_lastvisit' => (int) ($row['user_lastvisit'] ?? 0), + ]; + } + + private function restoreFounderState(): void + { + $state = $this->context->meta('founder_state', []); + if (!$state) + { + return; + } + $this->context->db->sql_query('UPDATE ' . USERS_TABLE . ' + SET user_lastmark = ' . (int) $state['user_lastmark'] . ', + user_lastvisit = ' . (int) $state['user_lastvisit'] . ' + WHERE user_id = ' . (int) $state['user_id']); + } +} diff --git a/src/QuickInstall/Sandbox/SeedRuntime/StateBuilder.php b/src/QuickInstall/Sandbox/SeedRuntime/StateBuilder.php new file mode 100644 index 00000000..62ecfe19 --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedRuntime/StateBuilder.php @@ -0,0 +1,490 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstallSeed; + +use RuntimeException; + +class StateBuilder extends BaseBuilder +{ + public function seed(array $users, array $forums, array $content): void + { + $this->seedAttachments($content['bbcode']['post_id']); + $this->reportPost($content['bbcode_reply']['post_id'], $users['regular']); + $messageIds = $this->seedPrivateMessages($users); + $this->seedUcpState($forums['lobby'], $content['normal']['topic_id']); + $this->seedNotifications($users, $forums, $content, $messageIds); + $this->refreshSearchIndex(); + $this->context->restoreUser(); + } + + private function seedAttachments(int $postId): void + { + $db = $this->context->db; + $result = $db->sql_query_limit( + 'SELECT topic_id, poster_id FROM ' . POSTS_TABLE . ' WHERE post_id = ' . $postId, + 1 + ); + $post = $db->sql_fetchrow($result); + $db->sql_freeresult($result); + if (!$post) + { + throw new RuntimeException('Attachment target post is unavailable.'); + } + + $definitions = [ + [ + 'real' => sprintf('sample-image-%d.png', $this->context->seed), + 'extension' => 'png', + 'mime' => 'image/png', + 'comment' => 'Sample PNG image attachment.', + 'content' => AssetFactory::png(640, 360, $this->context->seed + 12000), + ], + [ + 'real' => sprintf('sample-archive-%d.zip', $this->context->seed), + 'extension' => 'zip', + 'mime' => 'application/zip', + 'comment' => 'Sample ZIP archive attachment.', + 'content' => AssetFactory::zip($this->context->seed), + ], + ]; + + $directory = $this->context->root . $this->context->storageDirectory( + 'attachment', + (string) ($this->context->config['upload_path'] ?? 'files') + ) . '/'; + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) + { + throw new RuntimeException("Unable to create attachment directory: $directory"); + } + + foreach ($definitions as $index => $definition) + { + $physical = sprintf('qi_dev_%d_%s', $this->context->seed, hash('sha256', $definition['real'])); + $result = $db->sql_query_limit( + 'SELECT attach_id FROM ' . ATTACHMENTS_TABLE . " + WHERE physical_filename = '" . $db->sql_escape($physical) . "'", + 1 + ); + $attachmentId = (int) $db->sql_fetchfield('attach_id'); + $db->sql_freeresult($result); + $path = $directory . $physical; + $this->context->writeFile('attachment', $physical, $path, $definition['content']); + + if (!$attachmentId) + { + $db->sql_query('INSERT INTO ' . ATTACHMENTS_TABLE . ' ' . $db->sql_build_array('INSERT', [ + 'post_msg_id' => $postId, + 'topic_id' => (int) $post['topic_id'], + 'in_message' => 0, + 'poster_id' => (int) $post['poster_id'], + 'is_orphan' => 0, + 'physical_filename' => $physical, + 'real_filename' => $definition['real'], + 'download_count' => 7 + $index, + 'attach_comment' => $definition['comment'], + 'extension' => $definition['extension'], + 'mimetype' => $definition['mime'], + 'filesize' => strlen($definition['content']), + 'filetime' => time(), + 'thumbnail' => 0, + ])); + $attachmentId = $this->lastInsertedId(); + } + $this->context->addId('attachments', $attachmentId); + } + $db->sql_query('UPDATE ' . POSTS_TABLE . ' SET post_attachment = 1 WHERE post_id = ' . $postId); + } + + private function reportPost(int $postId, int $reporterId): void + { + $db = $this->context->db; + $result = $db->sql_query_limit('SELECT report_id FROM ' . REPORTS_TABLE . ' WHERE post_id = ' . $postId, 1); + $reportId = (int) $db->sql_fetchfield('report_id'); + $db->sql_freeresult($result); + if ($reportId) + { + $this->context->addId('reports', $reportId); + return; + } + + $result = $db->sql_query_limit( + 'SELECT topic_id, post_text, bbcode_uid, bbcode_bitfield, enable_bbcode, enable_smilies, enable_magic_url + FROM ' . POSTS_TABLE . ' WHERE post_id = ' . $postId, + 1 + ); + $post = $db->sql_fetchrow($result); + $db->sql_freeresult($result); + $reasonId = $this->reportReasonId(); + $db->sql_query('INSERT INTO ' . REPORTS_TABLE . ' ' . $db->sql_build_array('INSERT', [ + 'reason_id' => $reasonId, + 'post_id' => $postId, + 'pm_id' => 0, + 'user_id' => $reporterId, + 'user_notify' => 0, + 'report_closed' => 0, + 'report_time' => time(), + 'report_text' => 'This reply has been reported for moderator review.', + 'reported_post_text' => $post['post_text'], + 'reported_post_uid' => $post['bbcode_uid'], + 'reported_post_bitfield' => $post['bbcode_bitfield'], + 'reported_post_enable_bbcode' => $post['enable_bbcode'], + 'reported_post_enable_smilies' => $post['enable_smilies'], + 'reported_post_enable_magic_url' => $post['enable_magic_url'], + ])); + $reportId = $this->lastInsertedId(); + $this->context->addId('reports', $reportId); + $db->sql_query('UPDATE ' . POSTS_TABLE . ' SET post_reported = 1 WHERE post_id = ' . $postId); + $db->sql_query('UPDATE ' . TOPICS_TABLE . ' SET topic_reported = 1 WHERE topic_id = ' . (int) $post['topic_id']); + } + + private function seedPrivateMessages(array $users): array + { + $founder = $this->context->founderId(); + $messages = []; + $this->context->switchUser($users['admin']); + $messages['unread'] = $this->sendMessage( + $founder, + 'Unread private message', + 'This message has not been opened yet, so it should appear as unread in the inbox.' + ); + $this->context->switchUser($users['moderator']); + $messages['read'] = $this->sendMessage( + $founder, + 'Read private message', + 'This message has already been opened and should appear as read in the inbox.' + ); + $this->markMessageRead($messages['read'], $founder); + $this->context->switchUser($users['regular_2']); + $messages['reported'] = $this->sendMessage( + $founder, + 'Reported private message', + 'This message has an open report attached and should appear in the moderator queue.' + ); + $this->reportMessage($messages['reported'], $users['regular']); + $this->context->switchUser($founder); + $messages['sent'] = $this->sendMessage( + $users['regular'], + 'Sent private message', + 'This message was sent by the board founder and should appear in the sent messages folder.' + ); + $this->markMessageRead($messages['sent'], $users['regular']); + + for ($index = 0; $index < 5; $index++) + { + $sender = $users['posters'][$index]; + $recipient = $users['posters'][$index + 8]; + $this->context->switchUser($sender); + $messages['peer_' . $index] = $this->sendMessage( + $recipient, + sprintf('Peer private message %d', $index + 1), + 'This is a private conversation between two generated users.' + ); + } + $this->context->setMeta('messages', $messages); + return $messages; + } + + private function sendMessage(int $recipientId, string $label, string $message): int + { + $db = $this->context->db; + $subject = $this->context->prefix . $label; + $authorId = (int) $this->context->user->data['user_id']; + $result = $db->sql_query_limit( + 'SELECT msg_id FROM ' . PRIVMSGS_TABLE . " + WHERE author_id = $authorId + AND message_subject = '" . $db->sql_escape($subject) . "'", + 1 + ); + $messageId = (int) $db->sql_fetchfield('msg_id'); + $db->sql_freeresult($result); + if (!$messageId) + { + $uid = $bitfield = ''; + $options = 7; + generate_text_for_storage($message, $uid, $bitfield, $options, true, true, true); + $data = [ + 'address_list' => ['u' => [$recipientId => 'to']], + 'from_user_id' => $authorId, + 'from_user_ip' => '127.0.0.1', + 'from_username' => $this->context->user->data['username'], + 'enable_sig' => true, + 'enable_bbcode' => true, + 'enable_smilies' => true, + 'enable_urls' => true, + 'icon_id' => 0, + 'bbcode_uid' => $uid, + 'bbcode_bitfield' => $bitfield, + 'message' => $message, + ]; + submit_pm('post', $subject, $data, true); + $messageId = (int) ($data['msg_id'] ?? 0); + } + if (!$messageId) + { + throw new RuntimeException("Unable to create development private message: $subject"); + } + $this->context->addId('messages', $messageId); + return $messageId; + } + + private function markMessageRead(int $messageId, int $userId): void + { + $db = $this->context->db; + $db->sql_query('UPDATE ' . PRIVMSGS_TO_TABLE . ' + SET pm_unread = 0, pm_new = 0, folder_id = ' . PRIVMSGS_INBOX . ' + WHERE msg_id = ' . $messageId . ' AND user_id = ' . $userId); + $db->sql_query('UPDATE ' . PRIVMSGS_TO_TABLE . ' + SET folder_id = ' . PRIVMSGS_SENTBOX . ' + WHERE msg_id = ' . $messageId . ' AND folder_id = ' . PRIVMSGS_OUTBOX); + $db->sql_query('UPDATE ' . USERS_TABLE . ' + SET user_new_privmsg = CASE WHEN user_new_privmsg > 0 THEN user_new_privmsg - 1 ELSE 0 END, + user_unread_privmsg = CASE WHEN user_unread_privmsg > 0 THEN user_unread_privmsg - 1 ELSE 0 END + WHERE user_id = ' . $userId); + } + + private function reportMessage(int $messageId, int $reporterId): void + { + $db = $this->context->db; + $result = $db->sql_query_limit('SELECT report_id FROM ' . REPORTS_TABLE . ' WHERE pm_id = ' . $messageId, 1); + $reportId = (int) $db->sql_fetchfield('report_id'); + $db->sql_freeresult($result); + if ($reportId) + { + $this->context->addId('reports', $reportId); + return; + } + $result = $db->sql_query_limit( + 'SELECT message_text, bbcode_uid, bbcode_bitfield, enable_bbcode, enable_smilies, enable_magic_url + FROM ' . PRIVMSGS_TABLE . ' WHERE msg_id = ' . $messageId, + 1 + ); + $message = $db->sql_fetchrow($result); + $db->sql_freeresult($result); + $db->sql_query('INSERT INTO ' . REPORTS_TABLE . ' ' . $db->sql_build_array('INSERT', [ + 'reason_id' => $this->reportReasonId(), + 'post_id' => 0, + 'pm_id' => $messageId, + 'user_id' => $reporterId, + 'user_notify' => 0, + 'report_closed' => 0, + 'report_time' => time(), + 'report_text' => 'This private message has been reported for moderator review.', + 'reported_post_text' => $message['message_text'], + 'reported_post_uid' => $message['bbcode_uid'], + 'reported_post_bitfield' => $message['bbcode_bitfield'], + 'reported_post_enable_bbcode' => $message['enable_bbcode'], + 'reported_post_enable_smilies' => $message['enable_smilies'], + 'reported_post_enable_magic_url' => $message['enable_magic_url'], + ])); + $reportId = $this->lastInsertedId(); + $this->context->addId('reports', $reportId); + $db->sql_query('UPDATE ' . PRIVMSGS_TABLE . ' SET message_reported = 1 WHERE msg_id = ' . $messageId); + } + + private function reportReasonId(): int + { + $result = $this->context->db->sql_query_limit( + 'SELECT reason_id FROM ' . REPORTS_REASONS_TABLE . ' ORDER BY reason_order ASC', + 1 + ); + $id = (int) $this->context->db->sql_fetchfield('reason_id'); + $this->context->db->sql_freeresult($result); + return $id ?: 1; + } + + private function seedUcpState(int $forumId, int $topicId): void + { + $db = $this->context->db; + $userId = $this->context->founderId(); + $this->insertIfMissing( + BOOKMARKS_TABLE, + 'topic_id = ' . $topicId . ' AND user_id = ' . $userId, + ['topic_id' => $topicId, 'user_id' => $userId] + ); + $this->insertIfMissing( + TOPICS_WATCH_TABLE, + 'topic_id = ' . $topicId . ' AND user_id = ' . $userId, + ['topic_id' => $topicId, 'user_id' => $userId, 'notify_status' => 0] + ); + $this->insertIfMissing( + FORUMS_WATCH_TABLE, + 'forum_id = ' . $forumId . ' AND user_id = ' . $userId, + ['forum_id' => $forumId, 'user_id' => $userId, 'notify_status' => 0] + ); + + $subject = $this->context->prefix . 'Saved draft'; + $result = $db->sql_query_limit( + 'SELECT draft_id FROM ' . DRAFTS_TABLE . " + WHERE user_id = $userId AND draft_subject = '" . $db->sql_escape($subject) . "'", + 1 + ); + $draftId = (int) $db->sql_fetchfield('draft_id'); + $db->sql_freeresult($result); + if (!$draftId) + { + $db->sql_query('INSERT INTO ' . DRAFTS_TABLE . ' ' . $db->sql_build_array('INSERT', [ + 'user_id' => $userId, + 'topic_id' => 0, + 'forum_id' => $forumId, + 'save_time' => time(), + 'draft_subject' => $subject, + 'draft_message' => 'This draft has been saved but not posted.', + ])); + $draftId = $this->lastInsertedId(); + } + $this->context->addId('drafts', $draftId); + $this->context->setMeta('ucp', ['user_id' => $userId, 'forum_id' => $forumId, 'topic_id' => $topicId]); + } + + private function insertIfMissing(string $table, string $where, array $row): void + { + $db = $this->context->db; + $result = $db->sql_query_limit("SELECT * FROM $table WHERE $where", 1); + $exists = (bool) $db->sql_fetchrow($result); + $db->sql_freeresult($result); + if (!$exists) + { + $db->sql_query("INSERT INTO $table " . $db->sql_build_array('INSERT', $row)); + } + } + + private function seedNotifications(array $users, array $forums, array $content, array $messages): void + { + $db = $this->context->db; + $result = $db->sql_query('SELECT notification_type_id, notification_type_name FROM ' . NOTIFICATION_TYPES_TABLE); + $types = []; + while ($row = $db->sql_fetchrow($result)) + { + $types[$row['notification_type_name']] = (int) $row['notification_type_id']; + } + $db->sql_freeresult($result); + $recipient = $this->context->founderId(); + $definitions = [ + [ + 'type' => 'notification.type.post', + 'item' => $content['normal']['post_id'], + 'parent' => $content['normal']['topic_id'], + 'data' => [ + 'poster_id' => $users['regular'], + 'post_username' => '', + 'post_subject' => $this->context->prefix . 'Normal topic', + 'topic_title' => $this->context->prefix . 'Normal topic', + 'forum_id' => $forums['lobby'], + ], + ], + [ + 'type' => 'notification.type.quote', + 'item' => $content['bbcode_reply']['post_id'], + 'parent' => $content['bbcode']['topic_id'], + 'data' => [ + 'poster_id' => $users['moderator'], + 'post_subject' => $this->context->prefix . 'Re: BBCode and smiley showcase', + 'topic_title' => $this->context->prefix . 'BBCode and smiley showcase', + 'forum_id' => $forums['lobby'], + ], + ], + [ + 'type' => 'notification.type.pm', + 'item' => $messages['unread'], + 'parent' => 0, + 'data' => [ + 'from_user_id' => $users['admin'], + 'message_subject' => $this->context->prefix . 'Unread private message', + ], + ], + ]; + foreach ($definitions as $definition) + { + if (!isset($types[$definition['type']])) + { + throw new RuntimeException('Required notification type is unavailable: ' . $definition['type']); + } + $typeId = $types[$definition['type']]; + $result = $db->sql_query_limit( + 'SELECT notification_id FROM ' . NOTIFICATIONS_TABLE . ' + WHERE notification_type_id = ' . $typeId . ' + AND item_id = ' . (int) $definition['item'] . ' + AND user_id = ' . $recipient, + 1 + ); + $notificationId = (int) $db->sql_fetchfield('notification_id'); + $db->sql_freeresult($result); + if (!$notificationId) + { + $db->sql_query('INSERT INTO ' . NOTIFICATIONS_TABLE . ' ' . $db->sql_build_array('INSERT', [ + 'notification_type_id' => $typeId, + 'item_id' => (int) $definition['item'], + 'item_parent_id' => (int) $definition['parent'], + 'user_id' => $recipient, + 'notification_read' => 0, + 'notification_time' => time(), + 'notification_data' => serialize($definition['data']), + ])); + $notificationId = $this->lastInsertedId(); + } + $this->context->addId('notifications', $notificationId); + } + } + + private function refreshSearchIndex(): void + { + $searchClass = (string) ($this->context->config['search_type'] ?? '\\phpbb\\search\\fulltext_native'); + if ($this->context->container->has('search.backend_factory')) + { + $search = $this->context->container->get('search.backend_factory')->get_active(); + } + else + { + if (!class_exists($searchClass)) + { + throw new RuntimeException("Configured phpBB search backend is unavailable: $searchClass"); + } + $error = false; + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $search = new $searchClass( + $error, + $this->context->root, + $this->context->phpEx, + $this->context->auth, + $this->context->config, + $this->context->db, + $this->context->user, + $GLOBALS['phpbb_dispatcher'] + ); + /** @noinspection PhpConditionAlreadyCheckedInspection */ + if ($error) + { + throw new RuntimeException("Configured phpBB search backend failed: $error"); + } + } + if (!method_exists($search, 'index')) + { + throw new RuntimeException("Configured phpBB search backend cannot index posts: $searchClass"); + } + $postIds = $this->context->ids('posts'); + $result = $this->context->db->sql_query('SELECT post_id, post_subject, post_text, poster_id, forum_id + FROM ' . POSTS_TABLE . ' + WHERE ' . $this->context->db->sql_in_set('post_id', $postIds)); + while ($post = $this->context->db->sql_fetchrow($result)) + { + $search->index( + 'edit', + (int) $post['post_id'], + $post['post_text'], + $post['post_subject'], + (int) $post['poster_id'], + (int) $post['forum_id'] + ); + } + $this->context->db->sql_freeresult($result); + $this->context->setMeta('search_backend', $searchClass); + } +} diff --git a/src/QuickInstall/Sandbox/SeedRuntime/UserBuilder.php b/src/QuickInstall/Sandbox/SeedRuntime/UserBuilder.php new file mode 100644 index 00000000..bae9151b --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedRuntime/UserBuilder.php @@ -0,0 +1,255 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstallSeed; + +use RuntimeException; + +class UserBuilder extends BaseBuilder +{ + public function seed(): array + { + $registered = $this->requiredGroup('REGISTERED'); + $administrators = $this->requiredGroup('ADMINISTRATORS'); + $moderators = $this->requiredGroup('GLOBAL_MODERATORS'); + $users = []; + + for ($index = 1; $index <= 25; $index++) + { + $username = sprintf('qi_dev_%d_user_%02d', $this->context->seed, $index); + $userId = $this->createUser($username, $registered); + + $targetGroup = $index <= 2 ? $administrators : ($index <= 4 ? $moderators : $registered); + group_user_add($targetGroup, [$userId], false, false, true); + $this->applySignature($userId, $username); + $this->applyAvatar($userId, $index); + $users[$index] = $userId; + } + + $this->markInactive($users[24]); + $this->markBanned($users[25]); + $this->addWarnings([$users[5], $users[6]]); + + $roles = [ + 'admin' => $users[1], + 'admin_2' => $users[2], + 'moderator' => $users[3], + 'moderator_2' => $users[4], + 'regular' => $users[5], + 'regular_2' => $users[6], + 'regular_3' => $users[7], + 'inactive' => $users[24], + 'banned' => $users[25], + 'posters' => array_values(array_slice($users, 0, 23, true)), + ]; + $this->context->setMeta('users', $roles); + return $roles; + } + + public function seedVolume(int $count, bool $groups): array + { + $registered = $this->requiredGroup('REGISTERED'); + $users = []; + $slug = str_replace('-', '_', $this->context->preset); + for ($index = 1; $index <= $count; $index++) + { + $users[] = $this->createUser( + sprintf('qi_%s_%d_user_%03d', $slug, $this->context->seed, $index), + $registered + ); + } + + $posters = $users; + if ($groups && count($users) >= 5) + { + $moderators = array_slice($users, 0, 2); + $newlyRegistered = array_slice($users, -2); + $moderatorGroup = $this->context->groupId('GLOBAL_MODERATORS'); + $newUserGroup = $this->context->groupId('NEWLY_REGISTERED'); + if ($moderatorGroup) + { + group_user_add($moderatorGroup, $moderators, false, false, true); + } + if ($newUserGroup) + { + group_user_add($newUserGroup, $newlyRegistered); + } + $excluded = array_fill_keys($newlyRegistered, true); + $posters = array_values(array_filter($users, static function ($userId) use ($excluded) + { + return !isset($excluded[$userId]); + })); + } + + return $posters ?: $users; + } + + private function createUser(string $username, int $registeredGroup): int + { + $db = $this->context->db; + $result = $db->sql_query_limit( + 'SELECT user_id FROM ' . USERS_TABLE . " + WHERE username_clean = '" . $db->sql_escape(utf8_clean_string($username)) . "'", + 1 + ); + $userId = (int) $db->sql_fetchfield('user_id'); + $db->sql_freeresult($result); + if (!$userId) + { + $userId = (int) user_add([ + 'username' => $username, + 'user_password' => $this->context->container->get('passwords.manager')->hash(SeedContext::PASSWORD), + 'user_email' => sprintf('%s@example.test', $username), + 'group_id' => $registeredGroup, + 'user_type' => USER_NORMAL, + 'user_ip' => '127.0.0.1', + 'user_lang' => 'en', + ]); + } + if (!$userId) + { + throw new RuntimeException("Unable to create seed user: $username"); + } + $this->context->addId('users', $userId); + return $userId; + } + + private function requiredGroup(string $name): int + { + $id = $this->context->groupId($name); + if (!$id) + { + throw new RuntimeException("Required phpBB group is unavailable: $name"); + } + return $id; + } + + private function applySignature(int $userId, string $username): void + { + $db = $this->context->db; + $signature = sprintf('[b]%s[/b] — sample signature with [i]formatted text[/i].', $username); + $uid = $bitfield = ''; + $options = 7; + generate_text_for_storage($signature, $uid, $bitfield, $options, true, true, true); + $db->sql_query('UPDATE ' . USERS_TABLE . " + SET user_sig = '" . $db->sql_escape($signature) . "', + user_sig_bbcode_uid = '" . $db->sql_escape($uid) . "', + user_sig_bbcode_bitfield = '" . $db->sql_escape($bitfield) . "' + WHERE user_id = $userId"); + } + + private function applyAvatar(int $userId, int $index): void + { + $db = $this->context->db; + $avatarPath = $this->context->storageDirectory( + 'avatar', + (string) ($this->context->config['avatar_path'] ?? 'images/avatars/upload') + ); + $directory = $this->context->root . $avatarPath; + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) + { + throw new RuntimeException("Unable to create avatar directory: $directory"); + } + + $salt = (string) ($this->context->config['avatar_salt'] ?? ''); + $filename = $salt . '_' . $userId . '.png'; + $path = $directory . '/' . $filename; + $this->context->writeFile( + 'avatar', + $filename, + $path, + AssetFactory::png(80, 80, $this->context->seed * 100 + $index) + ); + + $avatar = sprintf('%d_qidev%d.png', $userId, $this->context->seed); + $db->sql_query('UPDATE ' . USERS_TABLE . " + SET user_avatar = '" . $db->sql_escape($avatar) . "', + user_avatar_type = 'avatar.driver.upload', + user_avatar_width = 80, + user_avatar_height = 80 + WHERE user_id = $userId"); + } + + private function markInactive(int $userId): void + { + $this->context->db->sql_query('UPDATE ' . USERS_TABLE . ' + SET user_type = ' . USER_INACTIVE . ', + user_inactive_reason = ' . INACTIVE_MANUAL . ', + user_inactive_time = ' . time() . ' + WHERE user_id = ' . $userId); + } + + private function markBanned(int $userId): void + { + $db = $this->context->db; + $table = $this->context->banTable(); + $result = $db->sql_query_limit('SELECT ban_id FROM ' . $table . ' WHERE ban_userid = ' . $userId, 1); + $banId = (int) $db->sql_fetchfield('ban_id'); + $db->sql_freeresult($result); + if (!$banId) + { + $row = [ + 'ban_userid' => $userId, + 'ban_start' => time(), + 'ban_end' => 0, + 'ban_reason' => 'Repeatedly ignored the community guidelines.', + ]; + if ($this->context->usesModernBans()) + { + $row += [ + 'ban_mode' => 'user', + 'ban_item' => (string) $userId, + 'ban_reason_display' => 'Please review the community guidelines before returning.', + ]; + } + else + { + $row += [ + 'ban_ip' => '', + 'ban_email' => '', + 'ban_exclude' => 0, + 'ban_give_reason' => 'Please review the community guidelines before returning.', + ]; + } + $db->sql_query('INSERT INTO ' . $table . ' ' . $db->sql_build_array('INSERT', $row)); + $banId = $this->lastInsertedId(); + } + $this->context->addId('bans', $banId); + } + + private function addWarnings(array $userIds): void + { + $db = $this->context->db; + foreach ($userIds as $userId) + { + $result = $db->sql_query_limit( + 'SELECT warning_id FROM ' . WARNINGS_TABLE . ' WHERE user_id = ' . (int) $userId . ' AND post_id = 0', + 1 + ); + $warningId = (int) $db->sql_fetchfield('warning_id'); + $db->sql_freeresult($result); + if (!$warningId) + { + $db->sql_query('INSERT INTO ' . WARNINGS_TABLE . ' ' . $db->sql_build_array('INSERT', [ + 'user_id' => (int) $userId, + 'post_id' => 0, + 'log_id' => 0, + 'warning_time' => time(), + ])); + $warningId = $this->lastInsertedId(); + $db->sql_query('UPDATE ' . USERS_TABLE . ' + SET user_warnings = user_warnings + 1, + user_last_warning = ' . time() . ' + WHERE user_id = ' . (int) $userId); + } + $this->context->addId('warnings', $warningId); + } + } +} diff --git a/src/QuickInstall/Sandbox/SeedRuntime/VolumeSeeder.php b/src/QuickInstall/Sandbox/SeedRuntime/VolumeSeeder.php new file mode 100644 index 00000000..9d30d142 --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedRuntime/VolumeSeeder.php @@ -0,0 +1,107 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstallSeed; + +use RuntimeException; + +/** Coordinates count-based tiny, extension-dev, load-test, and random plans. */ +class VolumeSeeder extends SeedPlan +{ + public function seed(): void + { + $counts = $this->resolveCounts(); + $this->context->setMeta('counts', $counts); + + $this->context->status('Creating ' . $counts['users'] . ' ' . $this->context->preset . ' ' + . $this->plural((int) $counts['users'], 'user', 'users') . '...'); + $users = (new UserBuilder($this->context))->seedVolume($counts['users'], $counts['groups']); + $this->context->saveManifest(); + + $forumCount = (int) $counts['categories'] * (int) $counts['forums_per_category']; + $this->context->status('Creating ' . $counts['categories'] . ' ' + . $this->plural((int) $counts['categories'], 'category', 'categories') . " and $forumCount " + . $this->plural($forumCount, 'forum', 'forums') . '...'); + $forums = (new ForumBuilder($this->context))->seedVolume( + $counts['categories'], + $counts['forums_per_category'] + ); + $this->context->saveManifest(); + + $this->context->status('Creating ' . $counts['topics'] . ' ' + . $this->plural((int) $counts['topics'], 'topic', 'topics') . ' with ' . $counts['replies'] . ' ' + . $this->plural((int) $counts['replies'], 'reply', 'replies') . ' each...'); + (new ContentBuilder($this->context))->seedVolume( + $forums, + $users, + $counts['topics'], + $counts['replies'] + ); + } + + public function verify(): void + { + $counts = $this->context->meta('counts', []); + if (!$counts) + { + throw new RuntimeException("{$this->context->preset} seed manifest has no resolved counts."); + } + + $expectedForums = (int) $counts['categories'] * (int) $counts['forums_per_category']; + $expectedPosts = (int) $counts['topics'] * ((int) $counts['replies'] + 1); + $checks = [ + $counts['users'] . ' ' . $this->plural((int) $counts['users'], 'user', 'users') => $this->existingIdCount(USERS_TABLE, 'user_id', $this->context->ids('users')) === (int) $counts['users'], + $counts['categories'] . ' ' . $this->plural((int) $counts['categories'], 'category', 'categories') => $this->existingIdCount(FORUMS_TABLE, 'forum_id', $this->context->ids('categories')) === (int) $counts['categories'], + $expectedForums . ' ' . $this->plural($expectedForums, 'forum', 'forums') => $this->existingIdCount(FORUMS_TABLE, 'forum_id', $this->context->ids('forums')) === $expectedForums, + $counts['topics'] . ' ' . $this->plural((int) $counts['topics'], 'topic', 'topics') => $this->existingIdCount(TOPICS_TABLE, 'topic_id', $this->context->ids('topics')) === (int) $counts['topics'], + $expectedPosts . ' ' . $this->plural($expectedPosts, 'post', 'posts') => $this->existingIdCount(POSTS_TABLE, 'post_id', $this->context->ids('posts')) === $expectedPosts, + ]; + $this->assertChecks($checks, "{$this->context->preset} seed verification failed: "); + + echo "Seeded {$this->context->preset} preset {$this->context->seed}: {$counts['users']} " + . $this->plural((int) $counts['users'], 'user', 'users') . ", {$counts['categories']} " + . $this->plural((int) $counts['categories'], 'category', 'categories') . ", $expectedForums " + . $this->plural($expectedForums, 'forum', 'forums') . ", {$counts['topics']} " + . $this->plural((int) $counts['topics'], 'topic', 'topics') . ", $expectedPosts " + . $this->plural($expectedPosts, 'post', 'posts') . ".\n"; + } + + /** @noinspection RandomApiMigrationInspection */ + private function resolveCounts(): array + { + $presets = [ + 'tiny' => ['users' => 3, 'categories' => 1, 'forums_per_category' => 2, 'topics' => 2, 'replies' => 2, 'groups' => false], + 'extension-dev' => ['users' => 10, 'categories' => 2, 'forums_per_category' => 3, 'topics' => 25, 'replies' => 10, 'groups' => true], + 'load-test' => ['users' => 100, 'categories' => 4, 'forums_per_category' => 5, 'topics' => 100, 'replies' => 20, 'groups' => true], + 'random' => ['users' => 100, 'categories' => 4, 'forums_per_category' => 5, 'topics' => 100, 'replies' => 20, 'groups' => true, 'randomize' => true], + ]; + if (!isset($presets[$this->context->preset])) + { + throw new RuntimeException("Unsupported volume preset: {$this->context->preset}"); + } + + $preset = $presets[$this->context->preset]; + if (empty($preset['randomize'])) + { + return $preset; + } + + mt_srand($this->context->seed); + $users = mt_rand(1, $preset['users']); + return [ + 'users' => $users, + 'categories' => mt_rand(1, $preset['categories']), + 'forums_per_category' => mt_rand(1, $preset['forums_per_category']), + 'topics' => mt_rand(1, $preset['topics']), + 'replies' => mt_rand(0, $preset['replies']), + 'groups' => $users >= 5, + ]; + } +} diff --git a/src/QuickInstall/Sandbox/SeedRuntime/run.php b/src/QuickInstall/Sandbox/SeedRuntime/run.php new file mode 100644 index 00000000..a9354b02 --- /dev/null +++ b/src/QuickInstall/Sandbox/SeedRuntime/run.php @@ -0,0 +1,117 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * @noinspection PhpDefineCanBeReplacedWithConstInspection + */ + +use QuickInstallSeed\SeedContext; +use QuickInstallSeed\Seeder; + +if (PHP_SAPI !== 'cli') +{ + fwrite(STDERR, "QuickInstall seed runtime may only run from the CLI.\n"); + exit(1); +} + +if (getenv('QUICKINSTALL_SEED_RUNTIME') !== '1') +{ + fwrite(STDERR, "QuickInstall seed runtime must be launched by QuickInstall.\n"); + exit(1); +} + +if (!isset($argv[1], $argv[2], $argv[3])) +{ + fwrite(STDERR, "Invalid QuickInstall seed runtime invocation: required arguments are missing.\n"); + exit(1); +} + +$preset = (string) $argv[1]; +$seed = (int) $argv[2]; +$action = (string) $argv[3]; +if (!in_array($preset, ['tiny', 'development', 'extension-dev', 'load-test', 'random'], true)) +{ + fwrite(STDERR, "Unknown seed preset: $preset\n"); + exit(1); +} +if ($seed < 1) +{ + fwrite(STDERR, "Seed must be a positive integer.\n"); + exit(1); +} +if (!in_array($action, ['seed', 'reset', 'replace'], true)) +{ + fwrite(STDERR, "Unknown seed action: $action\n"); + exit(1); +} + +$phpbb_root_path = rtrim((string) getcwd(), '/') . '/'; +$phpEx = 'php'; +define('IN_PHPBB', true); + +require_once $phpbb_root_path . 'common.' . $phpEx; + +/** @var \phpbb\user $user */ +/** @var \phpbb\auth\auth $auth */ +/** @var \phpbb\config\config $config */ +/** @var \phpbb\db\driver\driver_interface $db */ +/** @var \Symfony\Component\DependencyInjection\ContainerInterface $phpbb_container */ + +require_once $phpbb_root_path . 'includes/functions_user.' . $phpEx; +require_once $phpbb_root_path . 'includes/functions_content.' . $phpEx; +require_once $phpbb_root_path . 'includes/functions_posting.' . $phpEx; +require_once $phpbb_root_path . 'includes/functions_admin.' . $phpEx; +require_once $phpbb_root_path . 'includes/functions_privmsgs.' . $phpEx; +require_once $phpbb_root_path . 'includes/functions_acp.' . $phpEx; + +$user->session_begin(); +$result = $db->sql_query_limit( + 'SELECT * FROM ' . USERS_TABLE . ' WHERE user_type = ' . USER_FOUNDER . ' ORDER BY user_id ASC', + 1 +); +$founder = $db->sql_fetchrow($result); +$db->sql_freeresult($result); +if (!$founder) +{ + fwrite(STDERR, "Seed runtime requires a founder account.\n"); + exit(1); +} +$user->data = array_merge($user->data, $founder); +$user->data['is_registered'] = true; +$auth->acl($user->data); + +require_once __DIR__ . '/SeedContext.php'; +require_once __DIR__ . '/SeedPlan.php'; +require_once __DIR__ . '/UserBuilder.php'; +require_once __DIR__ . '/ForumBuilder.php'; +require_once __DIR__ . '/ContentBuilder.php'; +require_once __DIR__ . '/StateBuilder.php'; +require_once __DIR__ . '/DevelopmentSeeder.php'; +require_once __DIR__ . '/VolumeSeeder.php'; +require_once __DIR__ . '/Seeder.php'; + +try +{ + $context = new SeedContext( + $db, + $user, + $auth, + $config, + $phpbb_container, + $phpbb_root_path, + $phpEx, + $preset, + $seed + ); + (new Seeder($context))->run($action); + exit(0); +} +catch (Throwable $exception) +{ + fwrite(STDERR, 'Seed failed: ' . $exception->getMessage() . "\n"); + exit(1); +} diff --git a/src/QuickInstall/Sandbox/SeederWriter.php b/src/QuickInstall/Sandbox/SeederWriter.php index 048fe6d9..f6909118 100644 --- a/src/QuickInstall/Sandbox/SeederWriter.php +++ b/src/QuickInstall/Sandbox/SeederWriter.php @@ -10,9 +10,11 @@ namespace QuickInstall\Sandbox; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use RuntimeException; -/** Writes the phpBB-aware fixture script executed inside board containers. */ +/** Copies the unified preset runtime package into board runtime state. */ class SeederWriter { private Project $project; @@ -24,649 +26,42 @@ public function __construct(Project $project) public function write(string $name): string { - $path = $this->project->runtimePath($name) . '/seed.php'; - if (file_put_contents($path, $this->script()) === false) + $source = __DIR__ . '/SeedRuntime'; + $target = $this->project->runtimePath($name) . '/seed-runtime'; + if (!is_dir($source)) { - throw new RuntimeException("Unable to write $path"); + throw new RuntimeException("Seed runtime missing: $source"); } - return $path; - } - - private function script(): string - { - return <<<'PHP' - ['users' => 3, 'categories' => 1, 'forums_per_category' => 2, 'topics' => 2, 'replies' => 2, 'groups' => false], - 'extension-dev' => ['users' => 10, 'categories' => 2, 'forums_per_category' => 3, 'topics' => 25, 'replies' => 10, 'groups' => true], - 'load-test' => ['users' => 100, 'categories' => 4, 'forums_per_category' => 5, 'topics' => 100, 'replies' => 20, 'groups' => true], - 'random' => ['users' => 100, 'categories' => 4, 'forums_per_category' => 5, 'topics' => 100, 'replies' => 20, 'groups' => true, 'randomize' => true], -]; - -if (!isset($presets[$preset])) -{ - fwrite(STDERR, "Unknown seed preset: $preset\n"); - exit(1); -} - -if (!in_array($action, ['seed', 'reset', 'replace'], true)) -{ - fwrite(STDERR, "Unknown seed action: $action\n"); - exit(1); -} - -$phpbb_root_path = '/var/www/html/'; -$phpEx = 'php'; -define('IN_PHPBB', true); - -require_once $phpbb_root_path . 'common.' . $phpEx; -require_once $phpbb_root_path . 'includes/functions_user.' . $phpEx; -require_once $phpbb_root_path . 'includes/functions_content.' . $phpEx; -require_once $phpbb_root_path . 'includes/functions_posting.' . $phpEx; -require_once $phpbb_root_path . 'includes/functions_admin.' . $phpEx; - -$user->session_begin(); -$auth->acl($user->data); -$user->setup(); - -$admin_id = 2; -$user->data['user_id'] = $admin_id; -$user->data['username'] = 'admin'; -$user->data['is_registered'] = true; - -mt_srand($seed); -$counts = qi_seed_resolve_counts($presets[$preset]); - -if ($action === 'reset' || $action === 'replace') -{ - qi_seed_status('Resetting seed data...'); - $reset = qi_seed_reset($db, $seed); - echo "Reset seed $seed: {$reset['topics']} topics, {$reset['forums']} forums, {$reset['users']} users\n"; - if ($action === 'reset') - { - qi_seed_sync_user_post_counts($db); - exit(0); - } -} - -qi_seed_status('Seeding board...'); -$users = qi_seed_users($db, $phpbb_container, $counts['users'], $seed); -$posting_users = qi_seed_apply_group_variety($db, $users, !empty($counts['groups'])); -$forums = qi_seed_forums($db, $counts['categories'], $counts['forums_per_category'], $seed); -if (!$forums) -{ - $forum_id = qi_seed_first_postable_forum($db); - $forums = $forum_id ? [$forum_id] : []; -} - -if (!$forums) -{ - fwrite(STDERR, "No postable forum found\n"); - exit(1); -} - -qi_seed_status('Creating seeded topics and replies...'); -$created_topics = qi_seed_posts($forums, $posting_users, $counts['topics'], $counts['replies'], $seed); -qi_seed_status('Finalizing seed data...'); -qi_seed_mark_posts_counted($db, $seed); -qi_seed_sync_user_post_counts($db); - -echo "Seeded preset $preset: " . count($users) . " users available, " . count($forums) . " forums available, $created_topics topics\n"; - -function qi_seed_status(string $message): void -{ - echo $message . "\n"; - flush(); -} - -function qi_seed_reset($db, int $seed): array -{ - $topic_ids = qi_seed_ids_by_like($db, TOPICS_TABLE, 'topic_id', 'topic_title', sprintf('QI seeded topic %d-', $seed) . '%'); - $forum_ids = qi_seed_ids_by_like($db, FORUMS_TABLE, 'forum_id', 'forum_name', sprintf('QI seed %d ', $seed) . '%'); - $user_ids = qi_seed_ids_by_like($db, USERS_TABLE, 'user_id', 'username', sprintf('qi_user_%d_', $seed) . '%'); - - if ($topic_ids) - { - delete_topics('topic_id', $topic_ids, true, true, true); - } - - if ($forum_ids) - { - $db->sql_query('DELETE FROM ' . ACL_GROUPS_TABLE . ' WHERE ' . $db->sql_in_set('forum_id', $forum_ids)); - $db->sql_query('DELETE FROM ' . ACL_USERS_TABLE . ' WHERE ' . $db->sql_in_set('forum_id', $forum_ids)); - $db->sql_query('DELETE FROM ' . FORUMS_TRACK_TABLE . ' WHERE ' . $db->sql_in_set('forum_id', $forum_ids)); - $db->sql_query('DELETE FROM ' . FORUMS_WATCH_TABLE . ' WHERE ' . $db->sql_in_set('forum_id', $forum_ids)); - $db->sql_query('DELETE FROM ' . FORUMS_ACCESS_TABLE . ' WHERE ' . $db->sql_in_set('forum_id', $forum_ids)); - $db->sql_query('DELETE FROM ' . FORUMS_TABLE . ' WHERE ' . $db->sql_in_set('forum_id', $forum_ids)); - - $new_id = 1; - recalc_nested_sets($new_id, 'forum_id', FORUMS_TABLE); - } - - if ($user_ids) - { - user_delete('remove', $user_ids); - } - - qi_seed_sync_user_post_counts($db); - qi_seed_clear_caches(); - - return [ - 'topics' => count($topic_ids), - 'forums' => count($forum_ids), - 'users' => count($user_ids), - ]; -} - -function qi_seed_ids_by_like($db, string $table, string $id_column, string $text_column, string $pattern): array -{ - $sql = "SELECT $id_column - FROM $table - WHERE $text_column LIKE '" . $db->sql_escape($pattern) . "'"; - $result = $db->sql_query($sql); - $ids = []; - while ($row = $db->sql_fetchrow($result)) - { - $ids[] = (int) $row[$id_column]; - } - $db->sql_freeresult($result); - - return $ids; -} - -function qi_seed_sync_user_post_counts($db): void -{ - $db->sql_query('UPDATE ' . USERS_TABLE . ' - SET user_posts = 0'); - - $sql = 'SELECT poster_id, COUNT(post_id) AS post_count - FROM ' . POSTS_TABLE . ' - WHERE poster_id > 1 - AND post_postcount = 1 - AND post_visibility = ' . ITEM_APPROVED . ' - GROUP BY poster_id'; - $result = $db->sql_query($sql); - while ($row = $db->sql_fetchrow($result)) - { - $db->sql_query('UPDATE ' . USERS_TABLE . ' - SET user_posts = ' . (int) $row['post_count'] . ' - WHERE user_id = ' . (int) $row['poster_id']); - } - $db->sql_freeresult($result); -} - -function qi_seed_mark_posts_counted($db, int $seed): void -{ - $pattern = sprintf('%%QI seeded topic %d-%%', $seed); - $db->sql_query('UPDATE ' . POSTS_TABLE . ' - SET post_postcount = 1 - WHERE post_subject LIKE \'' . $db->sql_escape($pattern) . '\''); -} - -function qi_seed_resolve_counts(array $preset): array -{ - if (empty($preset['randomize'])) - { - return $preset; - } - - $users = mt_rand(1, $preset['users']); - return [ - 'users' => $users, - 'categories' => mt_rand(1, $preset['categories']), - 'forums_per_category' => mt_rand(1, $preset['forums_per_category']), - 'topics' => mt_rand(1, $preset['topics']), - 'replies' => mt_rand(0, $preset['replies']), - 'groups' => $users >= 5, - ]; -} - -function qi_seed_first_postable_forum($db): int -{ - $sql = 'SELECT forum_id FROM ' . FORUMS_TABLE . ' WHERE forum_type = ' . FORUM_POST . ' ORDER BY forum_id ASC'; - $result = $db->sql_query_limit($sql, 1); - $row = $db->sql_fetchrow($result); - $db->sql_freeresult($result); - - return $row ? (int) $row['forum_id'] : 0; -} - -function qi_seed_users($db, $phpbb_container, int $count, int $seed): array -{ - $users = []; - $passwords = $phpbb_container->get('passwords.manager'); - - for ($i = 1; $i <= $count; $i++) - { - $username = sprintf('qi_user_%d_%02d', $seed, $i); - $sql = 'SELECT user_id FROM ' . USERS_TABLE . " WHERE username_clean = '" . $db->sql_escape(utf8_clean_string($username)) . "'"; - $result = $db->sql_query_limit($sql, 1); - $row = $db->sql_fetchrow($result); - $db->sql_freeresult($result); - - if ($row) + $this->project->deleteTree($target); + if (!mkdir($target, 0775, true) && !is_dir($target)) { - $users[] = [ - 'user_id' => (int) $row['user_id'], - 'username' => $username, - 'user_colour' => '', - ]; - continue; + throw new RuntimeException("Unable to create seed runtime directory: $target"); } - $user_id = user_add([ - 'username' => $username, - 'user_password' => $passwords->hash('password'), - 'user_email' => sprintf('%s@example.test', $username), - 'group_id' => 2, - 'user_type' => USER_NORMAL, - 'user_ip' => '127.0.0.1', - 'user_lang' => 'en', - ]); - - if ($user_id !== false) + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($iterator as $item) { - $users[] = [ - 'user_id' => (int) $user_id, - 'username' => $username, - 'user_colour' => '', - ]; - } - } - - return $users ?: [[ - 'user_id' => 2, - 'username' => 'admin', - 'user_colour' => 'AA0000', - ]]; -} - -function qi_seed_apply_group_variety($db, array $users, bool $enabled): array -{ - if (!$enabled || count($users) < 5) - { - return $users; - } - - $moderators = array_slice($users, 0, min(2, count($users))); - $newly_registered = array_slice($users, -min(2, max(1, count($users) - count($moderators)))); - - qi_seed_add_users_to_group($db, $moderators, 'GLOBAL_MODERATORS'); - qi_seed_set_default_group($db, $moderators, 'GLOBAL_MODERATORS'); - qi_seed_add_users_to_group($db, $newly_registered, 'NEWLY_REGISTERED'); - - $newly_registered_ids = array_fill_keys(array_map(static function ($user) { - return (int) $user['user_id']; - }, $newly_registered), true); - - $posting_users = array_values(array_filter($users, static function ($user) use ($newly_registered_ids) { - return empty($newly_registered_ids[(int) $user['user_id']]); - })); - - return $posting_users ?: $users; -} - -function qi_seed_add_users_to_group($db, array $users, string $group_name): void -{ - if (!$users) - { - return; - } - - $group_id = qi_seed_group_id($db, $group_name); - if (!$group_id) - { - return; - } - - foreach ($users as $user_row) - { - $user_id = (int) $user_row['user_id']; - $sql = 'SELECT user_id FROM ' . USER_GROUP_TABLE . ' - WHERE group_id = ' . $group_id . ' - AND user_id = ' . $user_id; - $result = $db->sql_query_limit($sql, 1); - $exists = (bool) $db->sql_fetchrow($result); - $db->sql_freeresult($result); - if ($exists) - { - continue; - } - - $db->sql_query('INSERT INTO ' . USER_GROUP_TABLE . ' ' . $db->sql_build_array('INSERT', [ - 'group_id' => $group_id, - 'user_id' => $user_id, - 'group_leader' => 0, - 'user_pending' => 0, - ])); - } -} - -function qi_seed_set_default_group($db, array $users, string $group_name): void -{ - if (!$users) - { - return; - } - - $group_id = qi_seed_group_id($db, $group_name); - if (!$group_id) - { - return; - } - - $colour = qi_seed_group_field($db, $group_id, 'group_colour'); - $rank = (int) qi_seed_group_field($db, $group_id, 'group_rank'); - foreach ($users as $user_row) - { - $db->sql_query('UPDATE ' . USERS_TABLE . ' - SET group_id = ' . $group_id . ", - user_colour = '" . $db->sql_escape($colour) . "', - user_rank = " . $rank . ' - WHERE user_id = ' . (int) $user_row['user_id']); - } -} - -function qi_seed_group_field($db, int $group_id, string $field): string -{ - if (!in_array($field, ['group_colour', 'group_rank'], true)) - { - return ''; - } - - $sql = "SELECT $field - FROM " . GROUPS_TABLE . ' - WHERE group_id = ' . $group_id; - $result = $db->sql_query_limit($sql, 1); - $value = (string) $db->sql_fetchfield($field); - $db->sql_freeresult($result); - - return $value; -} - -function qi_seed_group_id($db, string $group_name): int -{ - $sql = 'SELECT group_id FROM ' . GROUPS_TABLE . " - WHERE group_name = '" . $db->sql_escape($group_name) . "'"; - $result = $db->sql_query_limit($sql, 1); - $group_id = (int) $db->sql_fetchfield('group_id'); - $db->sql_freeresult($result); - - return $group_id; -} - -function qi_seed_forums($db, int $categories, int $forums_per_category, int $seed): array -{ - $forum_ids = []; - $category_ids = []; - $permission_source = qi_seed_first_postable_forum($db); - - for ($category = 1; $category <= $categories; $category++) - { - $category_name = sprintf('QI seed %d category %02d', $seed, $category); - $category_id = qi_seed_forum_id_by_name($db, $category_name); - if (!$category_id) - { - $category_id = qi_seed_insert_forum($db, [ - 'parent_id' => 0, - 'forum_type' => FORUM_CAT, - 'forum_name' => $category_name, - 'forum_desc' => sprintf('Generated category for seed %d.', $seed), - ]); - } - $category_ids[] = $category_id; - - for ($forum = 1; $forum <= $forums_per_category; $forum++) - { - $forum_name = sprintf('QI seed %d forum %02d-%02d', $seed, $category, $forum); - $forum_id = qi_seed_forum_id_by_name($db, $forum_name); - if (!$forum_id) + $relative = substr($item->getPathname(), strlen($source) + 1); + $destination = $target . '/' . $relative; + if ($item->isDir()) { - $forum_id = qi_seed_insert_forum($db, [ - 'parent_id' => $category_id, - 'forum_type' => FORUM_POST, - 'forum_name' => $forum_name, - 'forum_desc' => sprintf('Generated forum %02d in category %02d.', $forum, $category), - ]); + if (!is_dir($destination) && !mkdir($destination, 0775, true) && !is_dir($destination)) + { + throw new RuntimeException("Unable to create seed runtime directory: $destination"); + } + continue; } - $forum_ids[] = $forum_id; - } - } - - $new_id = 1; - recalc_nested_sets($new_id, 'forum_id', FORUMS_TABLE); - - $forum_ids = array_values(array_unique(array_map('intval', $forum_ids))); - $permission_targets = array_values(array_unique(array_merge(array_map('intval', $category_ids), $forum_ids))); - if ($permission_source && $permission_targets) - { - qi_seed_copy_forum_permissions($db, $permission_source, $permission_targets); - } - - qi_seed_clear_caches(); - - return $forum_ids; -} - -function qi_seed_copy_forum_permissions($db, int $source_forum_id, array $target_forum_ids): void -{ - foreach ($target_forum_ids as $target_forum_id) - { - $target_forum_id = (int) $target_forum_id; - if ($target_forum_id === $source_forum_id) - { - continue; - } - - $db->sql_query('DELETE FROM ' . ACL_GROUPS_TABLE . ' WHERE forum_id = ' . $target_forum_id); - $db->sql_query('DELETE FROM ' . ACL_USERS_TABLE . ' WHERE forum_id = ' . $target_forum_id); - - qi_seed_copy_acl_rows($db, ACL_GROUPS_TABLE, $source_forum_id, $target_forum_id); - qi_seed_copy_acl_rows($db, ACL_USERS_TABLE, $source_forum_id, $target_forum_id); - } -} - -function qi_seed_copy_acl_rows($db, string $table, int $source_forum_id, int $target_forum_id): void -{ - $result = $db->sql_query('SELECT * FROM ' . $table . ' WHERE forum_id = ' . $source_forum_id); - while ($row = $db->sql_fetchrow($result)) - { - $row['forum_id'] = $target_forum_id; - $db->sql_query('INSERT INTO ' . $table . ' ' . $db->sql_build_array('INSERT', $row)); - } - $db->sql_freeresult($result); -} - -function qi_seed_clear_caches(): void -{ - global $auth, $cache; - - if (is_object($auth) && method_exists($auth, 'acl_clear_prefetch')) - { - $auth->acl_clear_prefetch(); - } - - if (is_object($cache) && method_exists($cache, 'purge')) - { - $cache->purge(); - } -} - -function qi_seed_forum_id_by_name($db, string $name): int -{ - $sql = 'SELECT forum_id FROM ' . FORUMS_TABLE . " WHERE forum_name = '" . $db->sql_escape($name) . "'"; - $result = $db->sql_query_limit($sql, 1); - $row = $db->sql_fetchrow($result); - $db->sql_freeresult($result); - - return $row ? (int) $row['forum_id'] : 0; -} - -function qi_seed_insert_forum($db, array $data): int -{ - $desc = $data['forum_desc']; - $desc_uid = $desc_bitfield = ''; - $desc_options = 7; - generate_text_for_storage($desc, $desc_uid, $desc_bitfield, $desc_options, true, true, true); - - $sql_ary = [ - 'parent_id' => (int) $data['parent_id'], - 'left_id' => 0, - 'right_id' => 0, - 'forum_parents' => '', - 'forum_name' => $data['forum_name'], - 'forum_desc' => $desc, - 'forum_desc_bitfield' => $desc_bitfield, - 'forum_desc_options' => $desc_options, - 'forum_desc_uid' => $desc_uid, - 'forum_link' => '', - 'forum_password' => '', - 'forum_image' => '', - 'forum_rules' => '', - 'forum_rules_link' => '', - 'forum_rules_bitfield' => '', - 'forum_rules_options' => 7, - 'forum_rules_uid' => '', - 'forum_type' => (int) $data['forum_type'], - 'forum_status' => ITEM_UNLOCKED, - 'forum_flags' => 48, - 'display_on_index' => 1, - 'enable_indexing' => 1, - 'enable_icons' => 1, - 'display_subforum_list' => 1, - ]; - - $db->sql_query('INSERT INTO ' . FORUMS_TABLE . ' ' . $db->sql_build_array('INSERT', $sql_ary)); - return (int) $db->sql_nextid(); -} - -function qi_seed_posts(array $forum_ids, array $authors, int $topics, int $replies, int $seed): int -{ - $created = 0; - $existing = qi_seed_existing_topic_count($seed); - - for ($topic = $existing + 1; $topic <= $topics; $topic++) - { - $forum_id = $forum_ids[array_rand($forum_ids)]; - $author = $authors[array_rand($authors)]; - $topic_id_hint = qi_seed_next_topic_id(); - $subject = sprintf('QI seeded topic %d-%02d', $seed, $topic_id_hint); - - qi_seed_set_author($author); - $data = qi_seed_post_data($forum_id, 0, $subject, sprintf("Seeded topic body %d.%02d\n\nUseful test content for extension development.", $seed, $topic_id_hint)); - $poll = []; - submit_post('post', $subject, '', POST_NORMAL, $poll, $data, true, true); - $created++; - - $topic_id = (int) ($data['topic_id'] ?? 0); - if (!$topic_id) - { - continue; - } - - for ($reply = 1; $reply <= $replies; $reply++) - { - $reply_author = $authors[array_rand($authors)]; - $reply_subject = 'Re: ' . $subject; - qi_seed_set_author($reply_author); - $reply_data = qi_seed_post_data($forum_id, $topic_id, $subject, sprintf('Seeded reply %d for topic %d.', $reply, $topic_id_hint)); - $reply_poll = []; - submit_post('reply', $reply_subject, '', POST_NORMAL, $reply_poll, $reply_data, true, true); - } - - // Heavy presets need periodic cleanup inside long-lived CLI process. - if ($topic % 5 === 0) - { - qi_seed_clear_caches(); - if (function_exists('gc_collect_cycles')) + if (!copy($item->getPathname(), $destination)) { - gc_collect_cycles(); + throw new RuntimeException("Unable to copy seed runtime file: $destination"); } } - } - - return $created; -} - -function qi_seed_existing_topic_count(int $seed): int -{ - global $db; - - $sql = 'SELECT COUNT(topic_id) AS topic_count - FROM ' . TOPICS_TABLE . " - WHERE topic_title LIKE '" . $db->sql_escape(sprintf('QI seeded topic %d-', $seed)) . "%'"; - $result = $db->sql_query($sql); - $count = (int) $db->sql_fetchfield('topic_count'); - $db->sql_freeresult($result); - - return $count; -} -function qi_seed_next_topic_id(): int -{ - global $db; - - $sql = 'SELECT MAX(topic_id) AS max_topic_id - FROM ' . TOPICS_TABLE; - $result = $db->sql_query($sql); - $next_id = (int) $db->sql_fetchfield('max_topic_id') + 1; - $db->sql_freeresult($result); - - return max(1, $next_id); -} - -function qi_seed_set_author(array $author): void -{ - global $user; - - $user->data['user_id'] = (int) $author['user_id']; - $user->data['username'] = $author['username']; - $user->data['username_clean'] = utf8_clean_string($author['username']); - $user->data['user_colour'] = $author['user_colour'] ?? ''; - $user->data['is_registered'] = true; -} - -function qi_seed_post_data(int $forum_id, int $topic_id, string $topic_title, string $message): array -{ - global $user; - - $uid = $bitfield = ''; - $options = 0; - generate_text_for_storage($message, $uid, $bitfield, $options, true, true, true); - - return [ - 'forum_id' => $forum_id, - 'topic_id' => $topic_id, - 'icon_id' => 0, - 'topic_title' => $topic_title, - 'topic_time_limit' => 0, - 'poster_id' => (int) $user->data['user_id'], - 'enable_bbcode' => true, - 'enable_smilies' => true, - 'enable_urls' => true, - 'enable_sig' => true, - 'message' => $message, - 'message_md5' => md5($message), - 'bbcode_bitfield' => $bitfield, - 'bbcode_uid' => $uid, - 'post_edit_locked' => 0, - 'notify_set' => false, - 'notify' => false, - 'post_time' => time(), - 'forum_name' => '', - 'enable_indexing' => true, - 'force_approved_state' => true, - ]; -} -PHP; + return $target; } } diff --git a/src/QuickInstall/Sandbox/Web/Application.php b/src/QuickInstall/Sandbox/Web/Application.php index 6cd06cff..cb289d00 100644 --- a/src/QuickInstall/Sandbox/Web/Application.php +++ b/src/QuickInstall/Sandbox/Web/Application.php @@ -21,6 +21,7 @@ use QuickInstall\Sandbox\DoctorService; use QuickInstall\Sandbox\ExtensionManager; use QuickInstall\Sandbox\Project; +use QuickInstall\Sandbox\SeedPresetCatalog; use QuickInstall\Sandbox\SourceService; use QuickInstall\Sandbox\StyleManager; use QuickInstall\Sandbox\UpdateService; @@ -216,6 +217,10 @@ private function handlePost(): void $port = (int) ($this->optional('port') ?: '8080'); $populate = $this->optional('populate') ?: 'none'; $this->validateBoardCreateOptions($db, $port, $populate); + if (SeedPresetCatalog::isDeprecated($populate)) + { + $this->output->write('Warning: ' . SeedPresetCatalog::deprecationMessage() . "\n"); + } (new BoardService($this->project, $this->output))->create($name, $version, $db, $port, $populate, $this->checked('debug'), $this->checked('replace')); $this->notice = "Created board: $name"; break; @@ -240,9 +245,13 @@ private function handlePost(): void case 'board_seed': $name = $this->required('name'); - $preset = $this->optional('preset') ?: 'extension-dev'; + $preset = $this->required('preset'); $seed = (int) ($this->optional('seed') ?: '1'); $this->validatePreset($preset); + if (SeedPresetCatalog::isDeprecated($preset)) + { + $this->output->write('Warning: ' . SeedPresetCatalog::deprecationMessage() . "\n"); + } if ($seed < 1) { throw new InvalidArgumentException('Seed must be a positive integer.'); @@ -408,18 +417,15 @@ private function validateBoardCreateOptions(string $db, int $port, string $popul { $this->validatePreset($populate); } - if ($db === 'sqlite' && $populate !== 'none') + if ($db === 'sqlite' && !SeedPresetCatalog::supportsSqlitePopulate($populate)) { - throw new InvalidArgumentException('SQLite boards support populate none only.'); + throw new InvalidArgumentException('SQLite boards support populate none, tiny, or development only.'); } } private function validatePreset(string $preset): void { - if (!in_array($preset, ['tiny', 'extension-dev', 'load-test', 'random'], true)) - { - throw new InvalidArgumentException('Preset must be one of: tiny, extension-dev, load-test, random.'); - } + SeedPresetCatalog::validate($preset); } private function render(): void @@ -541,8 +547,8 @@ private function viewData(?UpdateService $updates = null): array 'sources' => $viewSources, 'versionOptions' => $this->versionOptions($versions, $sources), 'dbOptions' => ['mariadb', 'mysql', 'postgres', 'sqlite'], - 'populateOptions' => ['none', 'tiny', 'extension-dev', 'load-test', 'random'], - 'presetOptions' => ['tiny', 'extension-dev', 'load-test', 'random'], + 'populateOptions' => array_merge(['none'], SeedPresetCatalog::visible()), + 'presetOptions' => SeedPresetCatalog::visible(), 'seedActionOptions' => ['seed', 'replace', 'reset'], ]; } diff --git a/src/QuickInstall/Sandbox/Web/templates/dashboard.php b/src/QuickInstall/Sandbox/Web/templates/dashboard.php index 66137e3a..0dbacd29 100644 --- a/src/QuickInstall/Sandbox/Web/templates/dashboard.php +++ b/src/QuickInstall/Sandbox/Web/templates/dashboard.php @@ -168,7 +168,7 @@ - + diff --git a/src/QuickInstall/Sandbox/bootstrap.php b/src/QuickInstall/Sandbox/bootstrap.php index 99f1daf2..39cb8dca 100644 --- a/src/QuickInstall/Sandbox/bootstrap.php +++ b/src/QuickInstall/Sandbox/bootstrap.php @@ -24,8 +24,9 @@ require_once __DIR__ . '/BoardService.php'; require_once __DIR__ . '/DockerComposeWriter.php'; require_once __DIR__ . '/StyleUninstallerWriter.php'; -require_once __DIR__ . '/BoardRunner.php'; +require_once __DIR__ . '/SeedPresetCatalog.php'; require_once __DIR__ . '/SeederWriter.php'; +require_once __DIR__ . '/BoardRunner.php'; require_once __DIR__ . '/CustomisationManagerInterface.php'; require_once __DIR__ . '/ExtensionManager.php'; require_once __DIR__ . '/StyleManager.php'; diff --git a/tests/Integration/ApplicationTest.php b/tests/Integration/ApplicationTest.php index edddc201..9cb3c0e3 100644 --- a/tests/Integration/ApplicationTest.php +++ b/tests/Integration/ApplicationTest.php @@ -31,6 +31,17 @@ public function testCommandHelpPrintsSpecificUsage(): void self::assertSame(0, $result['exit_code']); self::assertStringContainsString('Usage:', $result['output']); self::assertStringContainsString('qi board:create ', $result['output']); + self::assertStringContainsString('Default: none.', $result['output']); + } + + public function testSeedHelpShowsDevelopmentAndHidesDeprecatedPreset(): void + { + $result = $this->runApplication($this->createTempProjectRoot(), ['qi', 'board:seed', '--help']); + + self::assertSame(0, $result['exit_code']); + self::assertStringContainsString('tiny|development|load-test|random', $result['output']); + self::assertStringContainsString('Required fixture preset.', $result['output']); + self::assertStringNotContainsString('extension-dev', $result['output']); } public function testInitCreatesWorkspace(): void @@ -95,6 +106,30 @@ public function testBoardCreateValidationRunsBeforeExternalFetch(): void self::assertStringContainsString('--db must be one of: mariadb, mysql, postgres, sqlite.', $result['stderr']); } + public function testBoardCreateAllowsSupportedSqliteSeedPreset(): void + { + $root = $this->createTempProjectRoot(); + $this->addDownloadedSource($root, '3.3.14'); + + $result = $this->runApplication($root, [ + 'qi', 'board:create', 'demo', '--phpbb', '3.3.14', '--db', 'sqlite', '--populate', 'development', + ], "n\n"); + + self::assertSame(0, $result['exit_code']); + self::assertSame('sqlite', (new Project($root))->board('demo')['db']); + self::assertSame('development', (new Project($root))->board('demo')['populate']); + } + + public function testBoardCreateRejectsHeavySqliteSeedPreset(): void + { + $result = $this->runApplication($this->createTempProjectRoot(), [ + 'qi', 'board:create', 'demo', '--db', 'sqlite', '--populate', 'load-test', + ]); + + self::assertSame(1, $result['exit_code']); + self::assertStringContainsString('SQLite boards support --populate none, tiny, or development only.', $result['stderr']); + } + public function testBoardCreatePromptsToStartBoardAfterNextStep(): void { $root = $this->createTempProjectRoot(); @@ -107,6 +142,7 @@ public function testBoardCreatePromptsToStartBoardAfterNextStep(): void self::assertStringContainsString('php bin/qi board:start demo', $result['output']); self::assertStringContainsString('Run this command now? [Y/n]: ', $result['output']); self::assertStringNotContainsString('Started board: demo', $result['output']); + self::assertSame('none', (new Project($root))->board('demo')['populate']); } public function testBoardCreateDefaultsStartPromptToYes(): void @@ -137,13 +173,21 @@ public function testBoardSeedRejectsConflictingResetAndReplace(): void 'url' => 'http://localhost:8081/', ]); - $result = $this->runApplication($root, ['qi', 'board:seed', 'demo', '--reset', '--replace']); + $result = $this->runApplication($root, ['qi', 'board:seed', 'demo', '--preset', 'development', '--reset', '--replace']); self::assertSame(1, $result['exit_code']); self::assertSame('', $result['output']); self::assertStringContainsString('Use --reset or --replace, not both.', $result['stderr']); } + public function testBoardSeedRequiresPreset(): void + { + $result = $this->runApplication($this->createTempProjectRoot(), ['qi', 'board:seed', 'demo']); + + self::assertSame(1, $result['exit_code']); + self::assertStringContainsString('--preset is required.', $result['stderr']); + } + public function testUiStartHelpIsExposed(): void { $result = $this->runApplication($this->createTempProjectRoot(), ['qi', 'ui:start', '--help']); diff --git a/tests/Integration/WebApplicationTest.php b/tests/Integration/WebApplicationTest.php index 1de654c4..f144fb21 100644 --- a/tests/Integration/WebApplicationTest.php +++ b/tests/Integration/WebApplicationTest.php @@ -84,12 +84,46 @@ public function testRenderShowsCoreSandboxWorkflows(): void self::assertStringContainsString('/assets/sandbox-ui.js', $html); self::assertStringContainsString('', $html); + self::assertStringNotContainsString('