Skip to content

Commit a7a3d29

Browse files
committed
test(appconfig,userconfig): add tests for ownCloud migration fallback
Unit tests verifying that AppConfig and UserConfig gracefully handle missing database columns (type, lazy, flags, indexed) during ownCloud migration. Tests cover the loadConfig fallback path, re-throwing of unrelated exceptions, and column omission in insert queries. Signed-off-by: Anna Larch <anna@larch.dev> AI-Assisted-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Anna Larch <anna@nextcloud.com>
1 parent dac4f0d commit a7a3d29

2 files changed

Lines changed: 366 additions & 0 deletions

File tree

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
namespace Test;
9+
10+
use OC\AppConfig;
11+
use OC\Config\ConfigManager;
12+
use OC\Config\PresetManager;
13+
use OC\DB\Exceptions\DbalException;
14+
use OC\Memcache\Factory as CacheFactory;
15+
use Doctrine\DBAL\Exception\InvalidFieldNameException;
16+
use OCP\DB\Exception as DBException;
17+
use OCP\DB\IResult;
18+
use OCP\DB\QueryBuilder\IExpressionBuilder;
19+
use OCP\DB\QueryBuilder\IQueryBuilder;
20+
use OCP\ICache;
21+
use OCP\IConfig;
22+
use OCP\IDBConnection;
23+
use OCP\Security\ICrypto;
24+
use PHPUnit\Framework\MockObject\MockObject;
25+
use Psr\Log\LoggerInterface;
26+
27+
/**
28+
* Tests the ownCloud migration fallback in AppConfig.
29+
*
30+
* When migrating from ownCloud, the appconfig table lacks 'type' and 'lazy'
31+
* columns. AppConfig::loadConfig() must catch the resulting DBException and
32+
* retry with a query that only selects columns present in ownCloud's schema.
33+
*/
34+
class AppConfigMigrationFallbackTest extends TestCase {
35+
private IConfig&MockObject $config;
36+
private IDBConnection&MockObject $connection;
37+
private ConfigManager&MockObject $configManager;
38+
private PresetManager&MockObject $presetManager;
39+
private LoggerInterface&MockObject $logger;
40+
private ICrypto&MockObject $crypto;
41+
private CacheFactory&MockObject $cacheFactory;
42+
43+
protected function setUp(): void {
44+
parent::setUp();
45+
46+
$this->connection = $this->createMock(IDBConnection::class);
47+
$this->config = $this->createMock(IConfig::class);
48+
$this->configManager = $this->createMock(ConfigManager::class);
49+
$this->presetManager = $this->createMock(PresetManager::class);
50+
$this->logger = $this->createMock(LoggerInterface::class);
51+
$this->crypto = $this->createMock(ICrypto::class);
52+
$this->cacheFactory = $this->createMock(CacheFactory::class);
53+
}
54+
55+
private function getAppConfig(): AppConfig {
56+
$this->config->method('getSystemValueBool')
57+
->with('cache_app_config', true)
58+
->willReturn(true);
59+
$this->cacheFactory->method('isLocalCacheAvailable')->willReturn(false);
60+
61+
return new AppConfig(
62+
$this->connection,
63+
$this->config,
64+
$this->configManager,
65+
$this->presetManager,
66+
$this->logger,
67+
$this->crypto,
68+
$this->cacheFactory,
69+
);
70+
}
71+
72+
private function createInvalidFieldNameException(): DBException {
73+
$driverException = $this->createMock(\Doctrine\DBAL\Driver\Exception::class);
74+
$dbalException = new InvalidFieldNameException($driverException, null);
75+
return DbalException::wrap($dbalException);
76+
}
77+
78+
private function createMockQueryBuilder(): IQueryBuilder&MockObject {
79+
$expression = $this->createMock(IExpressionBuilder::class);
80+
$qb = $this->createMock(IQueryBuilder::class);
81+
$qb->method('from')->willReturn($qb);
82+
$qb->method('select')->willReturn($qb);
83+
$qb->method('addSelect')->willReturn($qb);
84+
$qb->method('where')->willReturn($qb);
85+
$qb->method('andWhere')->willReturn($qb);
86+
$qb->method('set')->willReturn($qb);
87+
$qb->method('expr')->willReturn($expression);
88+
$qb->method('insert')->willReturn($qb);
89+
$qb->method('setValue')->willReturn($qb);
90+
$qb->method('createNamedParameter')->willReturn('?');
91+
return $qb;
92+
}
93+
94+
/**
95+
* Test that loadConfig retries without type/lazy columns on InvalidFieldNameException.
96+
*/
97+
public function testLoadConfigFallsBackOnMissingColumns(): void {
98+
$exception = $this->createInvalidFieldNameException();
99+
100+
$successResult = $this->createMock(IResult::class);
101+
$successResult->method('fetchAll')->willReturn([
102+
['appid' => 'core', 'configkey' => 'vendor', 'configvalue' => 'owncloud'],
103+
['appid' => 'core', 'configkey' => 'installedat', 'configvalue' => '1234567890'],
104+
]);
105+
106+
$qb = $this->createMockQueryBuilder();
107+
// First call throws (columns missing), second call succeeds (fallback query)
108+
$qb->method('executeQuery')
109+
->willReturnOnConsecutiveCalls(
110+
$this->throwException($exception),
111+
$successResult,
112+
);
113+
114+
$this->connection->method('getQueryBuilder')->willReturn($qb);
115+
116+
$appConfig = $this->getAppConfig();
117+
118+
// getValueString triggers loadConfig internally
119+
$value = $appConfig->getValueString('core', 'vendor');
120+
$this->assertSame('owncloud', $value);
121+
}
122+
123+
/**
124+
* Test that non-INVALID_FIELD_NAME exceptions are re-thrown, not swallowed.
125+
*/
126+
public function testLoadConfigRethrowsOtherExceptions(): void {
127+
$driverException = $this->createMock(\Doctrine\DBAL\Driver\Exception::class);
128+
$dbalException = new \Doctrine\DBAL\Exception\SyntaxErrorException($driverException, null);
129+
$exception = DbalException::wrap($dbalException);
130+
131+
$qb = $this->createMockQueryBuilder();
132+
$qb->method('executeQuery')->willThrowException($exception);
133+
134+
$this->connection->method('getQueryBuilder')->willReturn($qb);
135+
136+
$appConfig = $this->getAppConfig();
137+
138+
$this->expectException(DBException::class);
139+
$appConfig->getValueString('core', 'vendor');
140+
}
141+
142+
/**
143+
* Test that insert omits lazy/type columns when migration is not completed.
144+
*/
145+
public function testInsertOmitsNewColumnsInFallbackMode(): void {
146+
$exception = $this->createInvalidFieldNameException();
147+
148+
$loadResult = $this->createMock(IResult::class);
149+
$loadResult->method('fetchAll')->willReturn([]);
150+
151+
$qb = $this->createMockQueryBuilder();
152+
153+
$callCount = 0;
154+
$qb->method('executeQuery')
155+
->willReturnCallback(function () use ($exception, $loadResult, &$callCount) {
156+
$callCount++;
157+
if ($callCount === 1) {
158+
throw $exception;
159+
}
160+
return $loadResult;
161+
});
162+
163+
// Verify insert() is called (meaning we reached the insert path)
164+
$qb->expects(self::once())->method('insert')->with('appconfig')->willReturn($qb);
165+
$qb->method('executeStatement')->willReturn(1);
166+
167+
// Track which columns are set via setValue
168+
$setColumns = [];
169+
$qb->method('setValue')
170+
->willReturnCallback(function (string $column) use ($qb, &$setColumns) {
171+
$setColumns[] = $column;
172+
return $qb;
173+
});
174+
175+
$this->connection->method('getQueryBuilder')->willReturn($qb);
176+
177+
$appConfig = $this->getAppConfig();
178+
$appConfig->setValueString('core', 'vendor', 'owncloud');
179+
180+
$this->assertContains('appid', $setColumns);
181+
$this->assertContains('configkey', $setColumns);
182+
$this->assertContains('configvalue', $setColumns);
183+
$this->assertNotContains('lazy', $setColumns, 'lazy column should be omitted in fallback mode');
184+
$this->assertNotContains('type', $setColumns, 'type column should be omitted in fallback mode');
185+
}
186+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
namespace Test\lib\Config;
9+
10+
use OC\Config\ConfigManager;
11+
use OC\Config\PresetManager;
12+
use OC\Config\UserConfig;
13+
use OC\DB\Exceptions\DbalException;
14+
use Doctrine\DBAL\Exception\InvalidFieldNameException;
15+
use OCP\DB\Exception as DBException;
16+
use OCP\DB\IResult;
17+
use OCP\DB\QueryBuilder\IExpressionBuilder;
18+
use OCP\DB\QueryBuilder\IQueryBuilder;
19+
use OCP\EventDispatcher\IEventDispatcher;
20+
use OCP\IConfig;
21+
use OCP\IDBConnection;
22+
use OCP\Security\ICrypto;
23+
use PHPUnit\Framework\MockObject\MockObject;
24+
use Psr\Log\LoggerInterface;
25+
use Test\TestCase;
26+
27+
/**
28+
* Tests the ownCloud migration fallback in UserConfig.
29+
*
30+
* When migrating from ownCloud, the preferences table lacks 'type', 'lazy',
31+
* 'flags', and 'indexed' columns. UserConfig::loadConfig() must catch the
32+
* resulting DBException and retry with a query that only selects columns
33+
* present in ownCloud's schema.
34+
*/
35+
class UserConfigMigrationFallbackTest extends TestCase {
36+
private IDBConnection&MockObject $connection;
37+
private IConfig&MockObject $config;
38+
private ConfigManager&MockObject $configManager;
39+
private PresetManager&MockObject $presetManager;
40+
private LoggerInterface&MockObject $logger;
41+
private ICrypto&MockObject $crypto;
42+
private IEventDispatcher&MockObject $dispatcher;
43+
44+
protected function setUp(): void {
45+
parent::setUp();
46+
47+
$this->connection = $this->createMock(IDBConnection::class);
48+
$this->config = $this->createMock(IConfig::class);
49+
$this->configManager = $this->createMock(ConfigManager::class);
50+
$this->presetManager = $this->createMock(PresetManager::class);
51+
$this->logger = $this->createMock(LoggerInterface::class);
52+
$this->crypto = $this->createMock(ICrypto::class);
53+
$this->dispatcher = $this->createMock(IEventDispatcher::class);
54+
}
55+
56+
private function getUserConfig(): UserConfig {
57+
return new UserConfig(
58+
$this->connection,
59+
$this->config,
60+
$this->configManager,
61+
$this->presetManager,
62+
$this->logger,
63+
$this->crypto,
64+
$this->dispatcher,
65+
);
66+
}
67+
68+
private function createInvalidFieldNameException(): DBException {
69+
$driverException = $this->createMock(\Doctrine\DBAL\Driver\Exception::class);
70+
$dbalException = new InvalidFieldNameException($driverException, null);
71+
return DbalException::wrap($dbalException);
72+
}
73+
74+
private function createMockQueryBuilder(): IQueryBuilder&MockObject {
75+
$expression = $this->createMock(IExpressionBuilder::class);
76+
$qb = $this->createMock(IQueryBuilder::class);
77+
$qb->method('from')->willReturn($qb);
78+
$qb->method('select')->willReturn($qb);
79+
$qb->method('addSelect')->willReturn($qb);
80+
$qb->method('where')->willReturn($qb);
81+
$qb->method('andWhere')->willReturn($qb);
82+
$qb->method('set')->willReturn($qb);
83+
$qb->method('expr')->willReturn($expression);
84+
$qb->method('insert')->willReturn($qb);
85+
$qb->method('setValue')->willReturn($qb);
86+
$qb->method('createNamedParameter')->willReturn('?');
87+
return $qb;
88+
}
89+
90+
/**
91+
* Test that loadConfig retries without new columns on InvalidFieldNameException.
92+
*/
93+
public function testLoadConfigFallsBackOnMissingColumns(): void {
94+
$exception = $this->createInvalidFieldNameException();
95+
96+
$successResult = $this->createMock(IResult::class);
97+
$successResult->method('fetchAll')->willReturn([
98+
['appid' => 'settings', 'configkey' => 'email', 'configvalue' => 'user@example.com'],
99+
]);
100+
101+
$qb = $this->createMockQueryBuilder();
102+
$qb->method('executeQuery')
103+
->willReturnOnConsecutiveCalls(
104+
$this->throwException($exception),
105+
$successResult,
106+
);
107+
108+
$this->connection->method('getQueryBuilder')->willReturn($qb);
109+
110+
$userConfig = $this->getUserConfig();
111+
112+
$value = $userConfig->getValueString('user1', 'settings', 'email');
113+
$this->assertSame('user@example.com', $value);
114+
}
115+
116+
/**
117+
* Test that non-INVALID_FIELD_NAME exceptions are re-thrown.
118+
*/
119+
public function testLoadConfigRethrowsOtherExceptions(): void {
120+
$driverException = $this->createMock(\Doctrine\DBAL\Driver\Exception::class);
121+
$dbalException = new \Doctrine\DBAL\Exception\SyntaxErrorException($driverException, null);
122+
$exception = DbalException::wrap($dbalException);
123+
124+
$qb = $this->createMockQueryBuilder();
125+
$qb->method('executeQuery')->willThrowException($exception);
126+
127+
$this->connection->method('getQueryBuilder')->willReturn($qb);
128+
129+
$userConfig = $this->getUserConfig();
130+
131+
$this->expectException(DBException::class);
132+
$userConfig->getValueString('user1', 'settings', 'email');
133+
}
134+
135+
/**
136+
* Test that insert omits new columns when migration is not completed.
137+
*/
138+
public function testInsertOmitsNewColumnsInFallbackMode(): void {
139+
$exception = $this->createInvalidFieldNameException();
140+
141+
$loadResult = $this->createMock(IResult::class);
142+
$loadResult->method('fetchAll')->willReturn([]);
143+
144+
$qb = $this->createMockQueryBuilder();
145+
146+
$callCount = 0;
147+
$qb->method('executeQuery')
148+
->willReturnCallback(function () use ($exception, $loadResult, &$callCount) {
149+
$callCount++;
150+
if ($callCount === 1) {
151+
throw $exception;
152+
}
153+
return $loadResult;
154+
});
155+
156+
$qb->expects(self::once())->method('insert')->with('preferences')->willReturn($qb);
157+
$qb->method('executeStatement')->willReturn(1);
158+
159+
$setColumns = [];
160+
$qb->method('setValue')
161+
->willReturnCallback(function (string $column) use ($qb, &$setColumns) {
162+
$setColumns[] = $column;
163+
return $qb;
164+
});
165+
166+
$this->connection->method('getQueryBuilder')->willReturn($qb);
167+
168+
$userConfig = $this->getUserConfig();
169+
$userConfig->setValueString('user1', 'settings', 'email', 'user@example.com');
170+
171+
$this->assertContains('userid', $setColumns);
172+
$this->assertContains('appid', $setColumns);
173+
$this->assertContains('configkey', $setColumns);
174+
$this->assertContains('configvalue', $setColumns);
175+
$this->assertNotContains('lazy', $setColumns, 'lazy column should be omitted in fallback mode');
176+
$this->assertNotContains('type', $setColumns, 'type column should be omitted in fallback mode');
177+
$this->assertNotContains('flags', $setColumns, 'flags column should be omitted in fallback mode');
178+
$this->assertNotContains('indexed', $setColumns, 'indexed column should be omitted in fallback mode');
179+
}
180+
}

0 commit comments

Comments
 (0)