diff --git a/controller/acp_controller.php b/controller/acp_controller.php index 2f29702..6e2751b 100644 --- a/controller/acp_controller.php +++ b/controller/acp_controller.php @@ -1928,8 +1928,10 @@ public function update_category_data(&$category_data, $copy_perm_from_id): array $row = $this->db->sql_fetchrow($result); $this->db->sql_freeresult($result); - $category_data_sql['left_id'] = $row['right_id'] + 1; - $category_data_sql['right_id'] = $row['right_id'] + 2; + // MAX() of an empty table is NULL, which MSSQL's driver + // returns as an empty string + $category_data_sql['left_id'] = (int) $row['right_id'] + 1; + $category_data_sql['right_id'] = (int) $row['right_id'] + 2; } $sql = 'INSERT INTO ' . $this->categories_table . ' ' . $this->db->sql_build_array('INSERT', $category_data_sql); @@ -2094,7 +2096,8 @@ protected function move_category($from_id, $to_id): array $row = $this->db->sql_fetchrow($result); $this->db->sql_freeresult($result); - $diff = '+ ' . ($row['right_id'] - $from_data['left_id'] + 1); + // (int): MAX() over no rows is NULL, an empty string from MSSQL's driver + $diff = '+ ' . ((int) $row['right_id'] - (int) $from_data['left_id'] + 1); } $sql = 'UPDATE ' . $this->categories_table . ' diff --git a/controller/article.php b/controller/article.php index fd9ec63..2ae4bad 100644 --- a/controller/article.php +++ b/controller/article.php @@ -236,7 +236,8 @@ public function show(): \Symfony\Component\HttpFoundation\Response if ($comment_topic_id) { $count = -1; - $sql = 'SELECT DISTINCT p.poster_id, p.post_time, p.post_subject, p.post_text, p.bbcode_uid, p.bbcode_bitfield, u.user_id, u.username + // No DISTINCT: one user per post, and MSSQL cannot compare post_text + $sql = 'SELECT p.poster_id, p.post_time, p.post_subject, p.post_text, p.bbcode_uid, p.bbcode_bitfield, u.user_id, u.username FROM ' . POSTS_TABLE . ' p, ' . USERS_TABLE . ' u WHERE p.topic_id = ' . (int) $comment_topic_id . ' AND (p.poster_id = u.user_id) diff --git a/controller/posting.php b/controller/posting.php index e77c763..a5f1d28 100644 --- a/controller/posting.php +++ b/controller/posting.php @@ -261,7 +261,9 @@ public function post_article(): \Symfony\Component\HttpFoundation\Response $delete_allowed = $edit_allowed = $article_author_id = false; if ($mode === 'edit' || $mode === 'delete') { - $sql = 'SELECT DISTINCT a.*, c.category_name, c.category_id + // No DISTINCT: one category per article, and MSSQL cannot compare + // the text columns DISTINCT would have to + $sql = 'SELECT a.*, c.category_name, c.category_id FROM ' . $this->articles_table . ' a, ' . $this->categories_table . ' c WHERE article_id = ' . $art_id . ' AND (c.category_id = a.article_category_id)'; diff --git a/controller/search.php b/controller/search.php index a0df3f8..e19d584 100644 --- a/controller/search.php +++ b/controller/search.php @@ -320,7 +320,9 @@ public function main(): \Symfony\Component\HttpFoundation\Response { $sql_sort = $sort_by_sql[$sort_key] . (($sort_dir == 'a') ? ' ASC' : ' DESC'); - $sql = 'SELECT DISTINCT a.*, u.user_id, u.username, u.user_colour + // No DISTINCT: one user per article, and MSSQL cannot compare the + // text columns DISTINCT would have to + $sql = 'SELECT a.*, u.user_id, u.username, u.user_colour FROM ' . $this->articles_table . ' a LEFT JOIN ' . USERS_TABLE . ' u ON (a.author_id = u.user_id AND a.author_type = 0) WHERE ' . $this->db->sql_in_set('article_id', $id_ary) . ' diff --git a/docs/testing.md b/docs/testing.md index 7ba6c52..4af1613 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1507,3 +1507,62 @@ also showed on the hierarchical index, and nothing showed once it was switched o Not covered: a board that never ran kinerity in a browser (the migration's off-by-default path is covered by PHPUnit), and other languages (the default text is English, as kinerity's was). + +## Enabling on MSSQL - 11/12/2026 + +The extension could not be enabled on a fresh MSSQL board, on phpBB 3.3 or 4.0: + +- `version_1_0_0` inserts the seven `kb_options` rows with fixed ids. MSSQL refuses an explicit + value for an identity column unless `IDENTITY_INSERT` is on, so the insert now switches it on + and off around the rows on MSSQL, the way phpBB's own installer fills its ACL options. +- `version_1_0_0` and `version_1_0_5` put numbers into `kb_config.config_value`, a text column + (`ntext` on MSSQL), which MSSQL refuses ("Operand type clash: int is incompatible with ntext"). + The values are now strings. +- `version_1_0_0` ran its extension-group query twice and never freed the first result, and + `version_2_0_0` never freed its `kb_config` result. MSSQL then refused the next transaction + ("New transaction is not allowed because there are other threads running in the session"). + +Once enabled, two more things failed on MSSQL: + +- Adding the first category added 1 to `MAX(right_id)` of an empty table. phpBB's MSSQL driver + returns that NULL as an empty string, and `'' + 1` is a TypeError in PHP 8. Moving a category + to the root had the same sum. Both now cast to an integer. +- Search, the article edit form and article comments used `SELECT DISTINCT` over rows with text + columns, which MSSQL cannot compare. Each join matches one row (one author, one category, one + poster), so `DISTINCT` is dropped. + +`kb_mssql_migration_test.php` (7 tests) checks the `IDENTITY_INSERT` statements on MSSQL and +their absence elsewhere, the string config values, and that no result is left open. +`kb_acp_first_category_test.php` checks the first root category with both NULL and an empty +string from `MAX()`. Both files fail on the code before this change. + +`tests/browser/kb_page_sweep.js` is a read-only check for any seeded board: it logs in as the +seed-forum administrator, opens the index, a category, an article, search, the queue, Manage +Your Articles, recent changes and its RSS feed, the add and edit forms, and every Knowledge Base +ACP module, and fails on a server error, a PHP error or phpBB's SQL error page. It loads only the +HTML (styles, scripts and images are skipped), because PHP's built-in server serves them slowly. + +```bash +KB_TEST_URL=http://localhost:8171 node /path/to/playwright-skill/run.js tests/browser/kb_page_sweep.js +``` + +Claude built a fresh seed-forum board with the full Knowledge Base fixtures for each of phpBB +3.3.19 and 4.0.0-a3-dev on SQLite, MySQL 8.4, MariaDB, PostgreSQL 16 and MSSQL (SQL Server 2022), +ten boards. On each, the sweep loaded 24 of 24 pages cleanly, and disabling, purging and enabling +again through phpBB's CLI left no Knowledge Base migrations, tables, settings or ACP modules after +the purge and all 40 migrations done after the enable. MSSQL boards run PHP 8.4 in seed-forum's +driver image and are served with `bin/serve-board.py`; on a 3.3 board served that way, turn +`enable_mod_rewrite` off first, since PHP's built-in server rewrites no clean URLs. + +The fresh-PostgreSQL fix (#12) gives `version_2_0_30`'s new option an explicit id, which MSSQL also +only takes with `IDENTITY_INSERT` on, so `version_2_0_30::add_permission()` now switches it on and off +around that insert on MSSQL as well; `kb_mssql_migration_test.php` covers it. The PostgreSQL boards, +and an MSSQL 3.3 board run a second time, used this change merged with that fix and the wrapper, +before #12 reached `main`. + +Building these boards needed three seed-forum fixes: a cache purge after enabling (seeds could not +find the extension's services), GD in the MSSQL PHP image (the image fixtures), and several +workers for `serve-board.py` (a browser's unused early connection held the single one). + +The PHPUnit suite passes on phpBB 3.3.x and master with the MySQL and PostgreSQL fixtures above: +511 tests, none skipped. diff --git a/migrations/version_1_0_0.php b/migrations/version_1_0_0.php index 3c77fdb..2795b9d 100644 --- a/migrations/version_1_0_0.php +++ b/migrations/version_1_0_0.php @@ -346,7 +346,6 @@ public function set_default_config() FROM ' . $this->table_prefix . 'extensions e, ' . $this->table_prefix . 'extension_groups g WHERE e.group_id = g.group_id AND g.allow_group = 1'; - $this->db->sql_query($sql); $result = $this->db->sql_query($sql); while ($row = $this->db->sql_fetchrow($result)) { @@ -388,18 +387,23 @@ public function set_default_config() $result = $this->db->sql_query($sql); $row = $this->db->sql_fetchrow($result); $this->db->sql_freeresult($result); + // Every value is a string: config_value is a text column, and MSSQL + // refuses to put a number into one ("Operand type clash: int is + // incompatible with ntext") + $rows = [ + 'allow_attachments' => '0', + 'max_filesize' => (string) $max_filesize, + 'extensions' => $extensions, + 'thumbnail' => '0', + 'max_attachments' => '1', + ]; if (empty($row)) { - $sql = 'INSERT INTO ' . $this->table_prefix . 'kb_config' . ' (config_name, config_value) VALUES - (\'allow_attachments\', 0), - (\'anounce\', 0), - (\'articles_per_page\', 10), - (\'forum_id\', 0), - (\'max_filesize\', ' . $max_filesize . '), - (\'extensions\', \'' . $extensions . '\'), - (\'thumbnail\', 0), - (\'max_attachments\', 1) - '; + $rows += [ + 'anounce' => '0', + 'articles_per_page' => '10', + 'forum_id' => '0', + ]; } else { @@ -413,15 +417,14 @@ public function set_default_config() $sql = 'ALTER TABLE ' . $this->table_prefix . 'kb_config CHANGE config_value config_value TEXT CHARACTER SET utf8 COLLATE utf8_bin'; $this->db->sql_query($sql); } - $sql = 'INSERT INTO ' . $this->table_prefix . 'kb_config' . ' (config_name, config_value) VALUES - (\'allow_attachments\', 0), - (\'max_filesize\', ' . $max_filesize . '), - (\'extensions\', \'' . $extensions . '\'), - (\'thumbnail\', 0), - (\'max_attachments\', 1) - '; } - $this->db->sql_query($sql); + + $sql_ary = []; + foreach ($rows as $config_name => $config_value) + { + $sql_ary[] = ['config_name' => $config_name, 'config_value' => $config_value]; + } + $this->db->sql_multi_insert($this->table_prefix . 'kb_config', $sql_ary); } public function update_kb_options_table() @@ -451,7 +454,19 @@ public function update_kb_options_table() 'is_local' => 1, ]; } + + // MSSQL only takes the fixed ids above with IDENTITY_INSERT on, + // the same way phpBB's own installer fills its ACL options + $identity_insert = strpos($this->db->get_sql_layer(), 'mssql') === 0; + if ($identity_insert) + { + $this->db->sql_query('SET IDENTITY_INSERT ' . $this->table_prefix . 'kb_options ON'); + } $this->db->sql_multi_insert($this->table_prefix . 'kb_options', $sql_ary); + if ($identity_insert) + { + $this->db->sql_query('SET IDENTITY_INSERT ' . $this->table_prefix . 'kb_options OFF'); + } } } diff --git a/migrations/version_1_0_5.php b/migrations/version_1_0_5.php index 43ac945..ad6d59b 100644 --- a/migrations/version_1_0_5.php +++ b/migrations/version_1_0_5.php @@ -54,8 +54,12 @@ public function update_data() public function add_config() { - $sql = 'INSERT INTO ' . $this->table_prefix . 'kb_config' . ' (config_name, config_value) VALUES - (\'sort_type\', 0)'; + // A string: config_value is a text column, which MSSQL will not + // compare with or fill from a number + $sql = 'INSERT INTO ' . $this->table_prefix . 'kb_config ' . $this->db->sql_build_array('INSERT', [ + 'config_name' => 'sort_type', + 'config_value' => '0', + ]); $this->db->sql_query($sql); } } diff --git a/migrations/version_2_0_0.php b/migrations/version_2_0_0.php index eb2645a..88b03ca 100644 --- a/migrations/version_2_0_0.php +++ b/migrations/version_2_0_0.php @@ -41,6 +41,8 @@ public function update_schema() { $this->config[$row['config_name']] = $row['config_value']; } + // MSSQL refuses to start a transaction while a result is still open + $this->db->sql_freeresult($result); // Drop table return [ diff --git a/migrations/version_2_0_30.php b/migrations/version_2_0_30.php index b3fa7fa..d7c84bb 100644 --- a/migrations/version_2_0_30.php +++ b/migrations/version_2_0_30.php @@ -39,9 +39,19 @@ public function add_permission(): void $next_id = (int) $this->db->sql_fetchfield('max_id') + 1; $this->db->sql_freeresult($result); + // MSSQL only takes an explicit id with IDENTITY_INSERT on + $identity_insert = strpos($this->db->get_sql_layer(), 'mssql') === 0; + if ($identity_insert) + { + $this->db->sql_query('SET IDENTITY_INSERT ' . $this->table_prefix . 'kb_options ON'); + } $this->db->sql_query('INSERT INTO ' . $this->table_prefix . 'kb_options ' . $this->db->sql_build_array('INSERT', [ 'auth_option_id' => $next_id, 'auth_option' => 'kb_m_change_author', 'is_global' => 0, 'is_local' => 1, ])); + if ($identity_insert) + { + $this->db->sql_query('SET IDENTITY_INSERT ' . $this->table_prefix . 'kb_options OFF'); + } } } diff --git a/tests/browser/kb_page_sweep.js b/tests/browser/kb_page_sweep.js new file mode 100644 index 0000000..6a9a3f3 --- /dev/null +++ b/tests/browser/kb_page_sweep.js @@ -0,0 +1,121 @@ +// Load every main Knowledge Base page and every Knowledge Base ACP module as +// the seeded administrator, and fail on a server error, a PHP error or +// phpBB's SQL error page. Read-only: it opens pages and submits nothing but +// the login forms, so it suits any seed-forum board on any database. +// +// KB_TEST_URL is the board's base URL, without a trailing slash. Routes go +// through app.php, so the board needs no clean-URL rewriting. +// KB_ARTIFACT_DIR optionally saves a screenshot of each page that fails. +const {chromium} = require('playwright'); +const fs = require('node:fs'); + +const base = process.env.KB_TEST_URL; +if (!/^http:\/\/(localhost|127\.0\.0\.1):\d+$/.test(base || '')) throw new Error('Set KB_TEST_URL to a disposable local board, for example http://localhost:8171'); +const out = process.env.KB_ARTIFACT_DIR; +const password = 'KbTest1234!'; + +// Text that only shows up when a page broke +const ERROR_MARKERS = ['SQL ERROR', 'Fatal error', 'Parse error', 'Uncaught', 'General Error', '[phpBB Debug]', 'Warning:', 'Notice:', 'Deprecated:']; + +// phpBB refuses a form submitted within two seconds of it being made +async function waitForFormAge(page) { + await page.waitForFunction(() => { + const field = document.querySelector('[name="creation_time"]'); + return field && Date.now() / 1000 > Number(field.value) + 2; + }); +} + +(async () => { + const browser = await chromium.launch({headless: true}); + const page = await browser.newPage({viewport: {width: 1280, height: 900}}); + // PHP's built-in server (MSSQL boards) serves styles and scripts slowly, so + // pages are checked once their HTML is in, without waiting for every asset + page.setDefaultTimeout(120000); + const ready = {waitUntil: 'domcontentloaded'}; + // Only the HTML is checked, and scripts would hold up domcontentloaded + await page.route(/\.(css|js|png|gif|jpe?g|svg|ico|woff2?|ttf)(\?|$)/, route => route.abort()); + const results = []; + + async function check(label, url) { + const response = await page.goto(url, ready); + const body = await page.content(); + const problems = []; + if (!response || response.status() >= 500) problems.push(`HTTP ${response ? response.status() : 'no response'}`); + for (const marker of ERROR_MARKERS) { + if (body.includes(marker)) problems.push(marker); + } + if (problems.length) { + // phpBB's error page message, for the SQL error and query + const text = await page.locator('body').innerText(); + const at = Math.max(0, text.search(/SQL ERROR|Fatal error|General Error|Warning|Notice/)); + problems.push(JSON.stringify(text.slice(at, at + 600))); + } + if (problems.length && out) { + fs.mkdirSync(out, {recursive: true}); + await page.screenshot({path: `${out}/${label.replace(/[^a-z0-9]+/gi, '-')}.png`, fullPage: true}); + } + results.push({label, status: response ? response.status() : 0, problems}); + return body; + } + + try { + // Board login + await page.goto(`${base}/ucp.php?mode=login`, ready); + await page.locator('#username').fill('admin'); + await page.locator('#password').fill(password); + await waitForFormAge(page); + await page.locator('[name="login"][type="submit"]').click(); + await page.waitForURL(url => !url.search.includes('mode=login'), ready); + + // Front end: the index, then the first category and article it links to + await check('index', `${base}/app.php/knowledgebase`); + const category = await firstLink(page, 'knowledgebase/category?id='); + const article = await firstLink(page, 'knowledgebase/article?k='); + if (!category || !article) throw new Error('The index links to no category or article; seed the board first'); + await check('category', category); + await check('article', article); + await check('search', `${base}/app.php/knowledgebase/search?keywords=guide&sf=all&cid=0`); + await check('queue', `${base}/app.php/knowledgebase/queue`); + await check('my articles', `${base}/app.php/knowledgebase/my_articles`); + await check('recent changes', `${base}/app.php/knowledgebase/recent_changes`); + await check('recent changes rss', `${base}/app.php/knowledgebase/recent_changes.rss`); + await check('posting form', `${base}/app.php/knowledgebase/posting?mode=add`); + await check('edit form', `${base}/app.php/knowledgebase/posting?mode=edit&id=${new URL(article).searchParams.get('k')}`); + + // ACP: it needs the session id in the address, and the password again + const settings = '/adm/index.php?i=-phpbbmodders-knowledgebase-acp-config_module&mode=settings'; + await page.goto(`${base}${settings}&sid=${await sessionCookie(page)}`, ready); + if (await page.locator('input[type="password"]').count()) { + await page.locator('input[type="password"]').fill(password); + await waitForFormAge(page); + // phpBB 4.0 keeps the ACP session in the cookie, so the address may not change + await Promise.all([page.waitForNavigation(ready), page.locator('[name="login"][type="submit"]').click()]); + } + await check('acp settings', `${base}${settings}&sid=${await sessionCookie(page)}`); + const modules = await page.locator('#menu a[href*="i=-phpbbmodders-knowledgebase-acp-"]').evaluateAll(links => links.map(link => [link.textContent.trim(), link.href])); + if (!modules.length) throw new Error('No Knowledge Base modules in the ACP menu'); + for (const [label, href] of modules) await check(`acp ${label}`, href); + } finally { + await browser.close(); + } + + const failed = results.filter(result => result.problems.length); + for (const result of results) console.log(`${result.problems.length ? 'FAIL' : 'ok '} ${result.status} ${result.label}${result.problems.length ? ': ' + result.problems.join(', ') : ''}`); + console.log(`${results.length - failed.length} of ${results.length} pages loaded cleanly`); + process.exitCode = failed.length ? 1 : 0; +})(); + +// The first link on the page whose address contains the text, or null +async function firstLink(page, text) { + return page.locator('a').evaluateAll((links, part) => { + // Skip same-page anchors, which load nothing + const link = links.find(a => a.href.includes(part) && !a.href.includes('#')); + return link ? link.href : null; + }, text); +} + +// The session id from phpBB's cookie, which every ACP address must carry +async function sessionCookie(page) { + const cookie = (await page.context().cookies()).find(item => item.name.endsWith('_sid')); + return cookie ? cookie.value : ''; +} diff --git a/tests/kb_acp_first_category_test.php b/tests/kb_acp_first_category_test.php new file mode 100644 index 0000000..e438e4e --- /dev/null +++ b/tests/kb_acp_first_category_test.php @@ -0,0 +1,62 @@ +createMock(\phpbb\db\driver\driver_interface::class); + $db->method('sql_query')->willReturn(true); + $db->method('sql_fetchrow')->willReturn(['right_id' => $empty_max]); + $db->method('sql_build_array')->willReturnCallback(function ($type, $data) use (&$inserted) { + $inserted = $data; + // Stop before the log and permission steps that follow the INSERT + throw new \RuntimeException('insert built'); + }); + + $controller = (new \ReflectionClass(acp_controller::class))->newInstanceWithoutConstructor(); + foreach (['db' => $db, 'categories_table' => 'categories'] as $name => $value) + { + $property = new \ReflectionProperty(acp_controller::class, $name); + $property->setValue($controller, $value); + } + + $category = ['category_name' => 'Getting Started', 'parent_id' => 0, 'category_parents' => '']; + try + { + $controller->update_category_data($category, 0); + $this->fail('Expected the stop at the INSERT'); + } + catch (\RuntimeException $error) + { + $this->assertSame('insert built', $error->getMessage()); + } + + $this->assertSame(1, $inserted['left_id']); + $this->assertSame(2, $inserted['right_id']); + } + + public function empty_max_values() + { + return ['NULL (MySQL, PostgreSQL, SQLite)' => [null], 'empty string (MSSQL)' => ['']]; + } +} diff --git a/tests/kb_mssql_migration_test.php b/tests/kb_mssql_migration_test.php new file mode 100644 index 0000000..bc05f14 --- /dev/null +++ b/tests/kb_mssql_migration_test.php @@ -0,0 +1,180 @@ + [['extension' => 'png', 'group_name' => 'IMAGES'], ['extension' => 'zip', 'group_name' => 'ARCHIVES']], + 'max_filesize' => [['config_value' => '262144']], + 'MAX(auth_option_id)' => [['max_id' => '7']], + 'kb_config' => $kb_config, + ]; + $rows = []; + + $db = $this->createMock(\phpbb\db\driver\driver_interface::class); + $db->method('get_sql_layer')->willReturn($layer); + $db->method('sql_query')->willReturnCallback(function ($sql) use (&$rows, $rowsets) { + $this->queries[] = $sql; + $rows = []; + if (strpos($sql, 'SELECT') === 0) + { + $this->open_results++; + foreach ($rowsets as $needle => $set) + { + if (strpos($sql, $needle) !== false) + { + $rows = $set; + break; + } + } + } + return true; + }); + $db->method('sql_fetchrow')->willReturnCallback(function () use (&$rows) { + return array_shift($rows) ?? false; + }); + $db->method('sql_fetchfield')->willReturnCallback(function ($field) use (&$rows) { + $row = array_shift($rows); + return $row[$field] ?? false; + }); + $db->method('sql_freeresult')->willReturnCallback(function () { + $this->open_results--; + return true; + }); + $db->method('sql_multi_insert')->willReturnCallback(function ($table, $rows) { + $this->queries[] = 'MULTI INSERT ' . $table; + $this->multi_inserts[] = [$table, $rows]; + return true; + }); + $db->method('sql_build_array')->willReturnCallback(function ($type, $data) { + return '(' . implode(', ', array_keys($data)) . ') VALUES (' . implode(', ', array_map(fn($v) => is_string($v) ? "'$v'" : $v, $data)) . ')'; + }); + + return $db; + } + + private function migration(string $class, string $layer, array $kb_config = []) + { + return new $class(new \phpbb\config\config([]), $this->fake_db($layer, $kb_config), $this->createMock(\phpbb\db\tools\tools_interface::class), '', 'php', 'phpbb_', []); + } + + public function test_mssql_inserts_the_fixed_option_ids_with_identity_insert_on() + { + $this->migration(version_1_0_0::class, 'mssqlnative')->update_kb_options_table(); + + $this->assertSame([ + 'SELECT * FROM phpbb_kb_options', + 'SET IDENTITY_INSERT phpbb_kb_options ON', + 'MULTI INSERT phpbb_kb_options', + 'SET IDENTITY_INSERT phpbb_kb_options OFF', + ], $this->queries); + $this->assertSame(range(1, 7), array_column($this->multi_inserts[0][1], 'auth_option_id')); + } + + public function test_other_databases_insert_the_option_ids_without_identity_insert() + { + foreach (['mysqli', 'postgres', 'sqlite3'] as $layer) + { + $this->queries = []; + $this->migration(version_1_0_0::class, $layer)->update_kb_options_table(); + + $this->assertSame(['SELECT * FROM phpbb_kb_options', 'MULTI INSERT phpbb_kb_options'], $this->queries, $layer); + } + } + + public function test_default_kb_config_values_are_strings_and_no_result_is_left_open() + { + // kb_config is empty on a fresh board + $this->migration(version_1_0_0::class, 'mssqlnative')->set_default_config(); + + $this->assertSame(0, $this->open_results, 'every SELECT result is freed'); + $this->assertCount(1, $this->multi_inserts); + [$table, $rows] = $this->multi_inserts[0]; + $this->assertSame('phpbb_kb_config', $table); + + $values = array_column($rows, 'config_value', 'config_name'); + $this->assertEqualsCanonicalizing( + ['allow_attachments', 'anounce', 'articles_per_page', 'forum_id', 'max_filesize', 'extensions', 'thumbnail', 'max_attachments'], + array_keys($values) + ); + foreach ($values as $name => $value) + { + $this->assertIsString($value, $name); + } + $this->assertSame('262144', $values['max_filesize']); + $this->assertSame('10', $values['articles_per_page']); + } + + public function test_the_sort_type_config_value_is_a_string() + { + $this->migration(version_1_0_5::class, 'mssqlnative')->add_config(); + + $this->assertSame(["INSERT INTO phpbb_kb_config (config_name, config_value) VALUES ('sort_type', '0')"], $this->queries); + } + + public function test_mssql_adds_the_change_author_option_with_identity_insert_on() + { + $this->migration(version_2_0_30::class, 'mssqlnative')->add_permission(); + + $this->assertSame([ + "SELECT auth_option_id FROM phpbb_kb_options WHERE auth_option = 'kb_m_change_author'", + 'SELECT MAX(auth_option_id) AS max_id FROM phpbb_kb_options', + 'SET IDENTITY_INSERT phpbb_kb_options ON', + "INSERT INTO phpbb_kb_options (auth_option_id, auth_option, is_global, is_local) VALUES (8, 'kb_m_change_author', 0, 1)", + 'SET IDENTITY_INSERT phpbb_kb_options OFF', + ], $this->queries); + } + + public function test_other_databases_add_the_change_author_option_without_identity_insert() + { + $this->migration(version_2_0_30::class, 'postgres')->add_permission(); + + $this->assertCount(3, $this->queries); + $this->assertStringStartsWith('INSERT INTO phpbb_kb_options', $this->queries[2]); + } + + public function test_reading_the_old_kb_config_frees_its_result() + { + $this->migration(version_2_0_0::class, 'mssqlnative', [['config_name' => 'sort_type', 'config_value' => '0']])->update_schema(); + + $this->assertSame(0, $this->open_results); + } +}