diff --git a/src/Halcyon/Datasource/DatasourceInterface.php b/src/Halcyon/Datasource/DatasourceInterface.php index 1c1fc25a9..4127d9b85 100644 --- a/src/Halcyon/Datasource/DatasourceInterface.php +++ b/src/Halcyon/Datasource/DatasourceInterface.php @@ -118,19 +118,23 @@ public function getPathsCacheKey(): string; /** * Get all available paths within this datasource. * - * This method returns an array, with all available paths as the key, and a boolean that represents whether the path - * can be handled or modified. + * This method returns an array, with all available paths as the key, and a value that represents whether the path + * can be handled or modified. A falsy value means the path cannot be handled; any truthy value means it can. + * + * Datasources that are able to determine a path's modification time cheaply may return that timestamp as the + * truthy value, allowing consumers to resolve modification times without a further lookup. * * Example: * * ```php * [ * 'path/to/file.md' => true, // (this path is available, and can be handled) - * 'path/to/file2.md' => false // (this path is available, but cannot be handled) + * 'path/to/file2.md' => 1559390400, // (as above, and was last modified at this timestamp) + * 'path/to/file3.md' => false // (this path is available, but cannot be handled) * ] * ``` * - * @return array An array of available paths alongside whether they can be handled. + * @return array An array of available paths alongside whether they can be handled. */ public function getAvailablePaths(): array; } diff --git a/src/Halcyon/Datasource/DbDatasource.php b/src/Halcyon/Datasource/DbDatasource.php index 5d83c4b04..90fb02169 100644 --- a/src/Halcyon/Datasource/DbDatasource.php +++ b/src/Halcyon/Datasource/DbDatasource.php @@ -341,25 +341,37 @@ public function delete(string $dirName, string $fileName, string $extension): bo */ public function lastModified(string $dirName, string $fileName, string $extension): ?int { - try { - return Carbon::parse($this->getQuery() - ->where('path', $this->makeFilePath($dirName, $fileName, $extension)) - ->first()->updated_at)->timestamp; - } catch (Exception $ex) { - return null; - } + $record = $this->getQuery() + ->select('updated_at') + ->where('path', $this->makeFilePath($dirName, $fileName, $extension)) + ->first(); + + return $record ? Carbon::parse($record->updated_at)->timestamp : null; } /** * @inheritDoc + * + * The key is versioned because the payload shape has changed; see getAvailablePaths(). */ public function getPathsCacheKey(): string { - return 'halcyon-datastore-db-' . $this->table . '-' . $this->source; + return 'halcyon-datastore-db-v2-' . $this->table . '-' . $this->source; } /** - * @inheritDoc + * Get all available paths within this datasource. + * + * Live records are mapped to their last modification timestamp rather than `true` so that + * consumers can resolve mtimes without a further query. Timestamps are truthy, so the + * existence / deletion contract of this map is unchanged. + * + * Note that the `halcyon.datasource.db.beforeGetAvailablePaths` event below may still + * return plain booleans, so consumers must treat the timestamps as an optimization rather + * than something they can rely on being present. + * + * @return array Paths that cannot be handled are `false`; live paths are + * a timestamp, or `true` when supplied by the event below. **/ public function getAvailablePaths(): array { @@ -377,14 +389,16 @@ public function getAvailablePaths(): array if (!$pathsCache = $this->fireEvent('halcyon.datasource.db.beforeGetAvailablePaths', [], true)) { // Only query for what is required $this->bindEventOnce('halcyon.datasource.db.extendQuery', function ($query, $ignoreDeleted) { - $query->addSelect('source', 'path', 'deleted_at'); + $query->addSelect('source', 'path', 'deleted_at', 'updated_at'); }); // Get all records stored in the DB $records = $this->getQuery(false)->get(); foreach ($records as $record) { - $pathsCache[$record->path] = !$record->deleted_at; + $pathsCache[$record->path] = $record->deleted_at + ? false + : Carbon::parse($record->updated_at)->timestamp; } } diff --git a/tests/Halcyon/DbDatasourceTest.php b/tests/Halcyon/DbDatasourceTest.php new file mode 100644 index 000000000..95a07513f --- /dev/null +++ b/tests/Halcyon/DbDatasourceTest.php @@ -0,0 +1,446 @@ +getSchemaBuilder()->create(self::TABLE, function ($table) { + $table->increments('id'); + $table->string('source')->index(); + $table->string('path')->index(); + $table->longText('content'); + $table->integer('file_size')->unsigned(); + $table->dateTime('updated_at')->nullable(); + $table->dateTime('deleted_at')->nullable(); + }); + + DB::table(self::TABLE)->insert([ + [ + 'source' => 'test', + 'path' => 'pages/index.htm', + 'content' => 'Index page', + 'file_size' => 10, + 'updated_at' => '2019-06-01 12:00:00', + 'deleted_at' => null, + ], + [ + 'source' => 'test', + 'path' => 'pages/about.htm', + 'content' => 'About page', + 'file_size' => 10, + 'updated_at' => '2019-06-05 12:00:00', + 'deleted_at' => null, + ], + [ + 'source' => 'test', + 'path' => 'pages/notes.md', + 'content' => 'Some notes', + 'file_size' => 10, + 'updated_at' => '2019-06-06 12:00:00', + 'deleted_at' => null, + ], + [ + 'source' => 'test', + 'path' => 'pages/deleted.htm', + 'content' => 'Deleted page', + 'file_size' => 12, + 'updated_at' => '2019-06-02 12:00:00', + 'deleted_at' => '2019-06-03 12:00:00', + ], + [ + 'source' => 'other', + 'path' => 'pages/other.htm', + 'content' => 'Other source page', + 'file_size' => 17, + 'updated_at' => '2019-06-04 12:00:00', + 'deleted_at' => null, + ], + ]); + + $this->datasource = new DbDatasource('test', self::TABLE); + } + + public function tearDown(): void + { + DB::connection()->getSchemaBuilder()->dropIfExists(self::TABLE); + + parent::tearDown(); + } + + /** + * Reads a row straight out of the table, bypassing the datasource. + */ + protected function rawRecord(string $path, string $source = 'test') + { + return DB::table(self::TABLE)->where('source', $source)->where('path', $path)->first(); + } + + // + // getAvailablePaths() + // + + public function testGetAvailablePathsReturnsTimestampsForLiveRecords() + { + $paths = $this->datasource->getAvailablePaths(); + + $this->assertSame(strtotime('2019-06-01 12:00:00'), $paths['pages/index.htm']); + } + + public function testGetAvailablePathsReturnsFalseForDeletedRecords() + { + $paths = $this->datasource->getAvailablePaths(); + + $this->assertFalse($paths['pages/deleted.htm']); + } + + public function testGetAvailablePathsIsScopedToTheSource() + { + $paths = $this->datasource->getAvailablePaths(); + + $this->assertArrayNotHasKey('pages/other.htm', $paths); + } + + public function testGetAvailablePathsHonoursTheBeforeEvent() + { + // The documented event contract returns booleans, which must keep working + $this->datasource->bindEvent('halcyon.datasource.db.beforeGetAvailablePaths', function () { + return ['pages/from-event.htm' => true, 'pages/gone.htm' => false]; + }); + + $this->assertSame( + ['pages/from-event.htm' => true, 'pages/gone.htm' => false], + $this->datasource->getAvailablePaths() + ); + } + + // + // selectOne() + // + + public function testSelectOneReturnsTheRecord() + { + $result = $this->datasource->selectOne('pages', 'index', 'htm'); + + $this->assertSame('index.htm', $result['fileName']); + $this->assertSame('Index page', $result['content']); + $this->assertSame(strtotime('2019-06-01 12:00:00'), $result['mtime']); + $this->assertSame('pages/index.htm', $result['record']->path); + } + + public function testSelectOneReturnsNullForMissingRecord() + { + $this->assertNull($this->datasource->selectOne('pages', 'nope', 'htm')); + } + + public function testSelectOneIgnoresDeletedRecords() + { + $this->assertNull($this->datasource->selectOne('pages', 'deleted', 'htm')); + } + + public function testSelectOneIsScopedToTheSource() + { + $this->assertNull($this->datasource->selectOne('pages', 'other', 'htm')); + } + + // + // select() + // + + public function testSelectReturnsEveryLiveRecordInTheDirectory() + { + $results = collect($this->datasource->select('pages'))->keyBy('fileName'); + + $this->assertEqualsCanonicalizing( + ['index.htm', 'about.htm', 'notes.md'], + $results->keys()->all() + ); + $this->assertSame('Index page', $results['index.htm']['content']); + $this->assertSame(strtotime('2019-06-01 12:00:00'), $results['index.htm']['mtime']); + } + + public function testSelectExcludesDeletedRecords() + { + $results = collect($this->datasource->select('pages'))->pluck('fileName'); + + $this->assertNotContains('deleted.htm', $results->all()); + } + + public function testSelectIsScopedToTheSource() + { + $results = collect($this->datasource->select('pages'))->pluck('fileName'); + + $this->assertNotContains('other.htm', $results->all()); + } + + public function testSelectFiltersByExtension() + { + $results = collect($this->datasource->select('pages', ['extensions' => ['md']])); + + $this->assertSame(['notes.md'], $results->pluck('fileName')->all()); + } + + public function testSelectFiltersByFileMatch() + { + $results = collect($this->datasource->select('pages', ['fileMatch' => 'ab*'])); + + $this->assertSame(['about.htm'], $results->pluck('fileName')->all()); + } + + public function testSelectLimitsReturnedColumns() + { + $results = $this->datasource->select('pages', ['columns' => ['fileName']]); + + $this->assertSame(['fileName'], array_keys($results[0])); + } + + public function testSelectTreatsWildcardColumnsAsAllColumns() + { + $results = $this->datasource->select('pages', ['columns' => ['*']]); + + $this->assertEqualsCanonicalizing( + ['fileName', 'content', 'mtime', 'record'], + array_keys($results[0]) + ); + } + + // + // insert() + // + + public function testInsertCreatesTheRecordAndReturnsItsSize() + { + $size = $this->datasource->insert('pages', 'created', 'htm', 'Created page'); + + $this->assertSame(12, $size); + + $record = $this->rawRecord('pages/created.htm'); + $this->assertSame('Created page', $record->content); + $this->assertSame(12, (int) $record->file_size); + $this->assertNotNull($record->updated_at); + $this->assertNull($record->deleted_at); + } + + public function testInsertThrowsWhenThePathAlreadyExists() + { + $this->expectException(FileExistsException::class); + + $this->datasource->insert('pages', 'index', 'htm', 'Replacement'); + } + + public function testInsertRevivesASoftDeletedRecord() + { + $this->datasource->insert('pages', 'deleted', 'htm', 'Revived page'); + + $record = $this->rawRecord('pages/deleted.htm'); + $this->assertSame('Revived page', $record->content); + $this->assertNull($record->deleted_at); + + // Revived in place rather than duplicated + $this->assertSame(1, DB::table(self::TABLE)->where('path', 'pages/deleted.htm')->count()); + } + + public function testInsertFiresTheBeforeInsertEvent() + { + $this->datasource->bindEvent('halcyon.datasource.db.beforeInsert', function (&$record) { + $record['content'] = 'Rewritten by the event'; + }); + + $this->datasource->insert('pages', 'created', 'htm', 'Created page'); + + $this->assertSame('Rewritten by the event', $this->rawRecord('pages/created.htm')->content); + } + + // + // update() + // + + public function testUpdateChangesContentAndTimestamp() + { + $size = $this->datasource->update('pages', 'index', 'htm', 'Updated page'); + + $this->assertSame(12, $size); + + $record = $this->rawRecord('pages/index.htm'); + $this->assertSame('Updated page', $record->content); + $this->assertGreaterThan( + strtotime('2019-06-01 12:00:00'), + strtotime($record->updated_at) + ); + } + + public function testUpdateRenamesTheRecord() + { + $this->datasource->update('pages', 'renamed', 'htm', 'Renamed page', 'index', 'htm'); + + $this->assertNull($this->rawRecord('pages/index.htm')); + $this->assertSame('Renamed page', $this->rawRecord('pages/renamed.htm')->content); + } + + public function testUpdateChangesTheExtension() + { + $this->datasource->update('pages', 'index', 'md', 'Now markdown', 'index', 'htm'); + + $this->assertNull($this->rawRecord('pages/index.htm')); + $this->assertSame('Now markdown', $this->rawRecord('pages/index.md')->content); + } + + public function testUpdateClearsTheDeletedFlag() + { + $this->datasource->update('pages', 'deleted', 'htm', 'Restored page'); + + $this->assertNull($this->rawRecord('pages/deleted.htm')->deleted_at); + } + + public function testUpdateFiresTheBeforeUpdateEvent() + { + $this->datasource->bindEvent('halcyon.datasource.db.beforeUpdate', function (&$data) { + $data['content'] = 'Rewritten by the event'; + }); + + $this->datasource->update('pages', 'index', 'htm', 'Updated page'); + + $this->assertSame('Rewritten by the event', $this->rawRecord('pages/index.htm')->content); + } + + // + // delete() + // + + public function testDeleteSoftDeletesTheRecord() + { + $this->assertTrue($this->datasource->delete('pages', 'index', 'htm')); + + $record = $this->rawRecord('pages/index.htm'); + $this->assertNotNull($record, 'The row should be retained'); + $this->assertNotNull($record->deleted_at); + $this->assertNull($this->datasource->selectOne('pages', 'index', 'htm')); + } + + public function testDeleteThrowsWhenNoRecordMatches() + { + $this->expectException(DeleteFileException::class); + + $this->datasource->delete('pages', 'nope', 'htm'); + } + + public function testDeleteThrowsForAnAlreadyDeletedRecord() + { + $this->expectException(DeleteFileException::class); + + $this->datasource->delete('pages', 'deleted', 'htm'); + } + + public function testForceDeleteRemovesTheRecord() + { + $this->assertTrue($this->datasource->forceDelete('pages', 'index', 'htm')); + + $this->assertNull($this->rawRecord('pages/index.htm')); + } + + // + // lastModified() + // + + public function testLastModifiedReturnsTheTimestamp() + { + $this->assertSame( + strtotime('2019-06-01 12:00:00'), + $this->datasource->lastModified('pages', 'index', 'htm') + ); + } + + public function testLastModifiedReturnsNullForMissingRecord() + { + $this->assertNull($this->datasource->lastModified('pages', 'nope', 'htm')); + } + + public function testLastModifiedIgnoresDeletedRecords() + { + $this->assertNull($this->datasource->lastModified('pages', 'deleted', 'htm')); + } + + public function testLastModifiedDoesNotSelectTheContentColumn() + { + DB::connection()->flushQueryLog(); + DB::connection()->enableQueryLog(); + + $this->datasource->lastModified('pages', 'index', 'htm'); + + $queries = DB::connection()->getQueryLog(); + DB::connection()->disableQueryLog(); + + // Selecting the whole row just to read a timestamp drags the template content + // across the wire on every cache validation + $this->assertCount(1, $queries); + $this->assertStringNotContainsString('*', $queries[0]['query']); + $this->assertStringContainsString('updated_at', $queries[0]['query']); + } + + // + // Misc + // + + public function testExtendQueryEventAppliesToReads() + { + $this->datasource->bindEvent('halcyon.datasource.db.extendQuery', function ($query) { + $query->where('path', 'pages/about.htm'); + }); + + $results = collect($this->datasource->select('pages'))->pluck('fileName'); + + $this->assertSame(['about.htm'], $results->all()); + } + + public function testGetPathsCacheKeyIsVersionedAndScoped() + { + $this->assertSame( + 'halcyon-datastore-db-v2-' . self::TABLE . '-test', + $this->datasource->getPathsCacheKey() + ); + + // The payload shape changed from booleans to timestamps, so the key must not + // collide with manifests written by earlier versions + $this->assertStringNotContainsString( + 'halcyon-datastore-db-' . self::TABLE, + $this->datasource->getPathsCacheKey() + ); + + $other = new DbDatasource('other', self::TABLE); + $this->assertNotSame($this->datasource->getPathsCacheKey(), $other->getPathsCacheKey()); + } + + public function testMakeCacheKeyIsDeterministic() + { + $this->assertSame( + $this->datasource->makeCacheKey('pages/index.htm'), + $this->datasource->makeCacheKey('pages/index.htm') + ); + $this->assertNotSame( + $this->datasource->makeCacheKey('pages/index.htm'), + $this->datasource->makeCacheKey('pages/about.htm') + ); + } + + public function testGetPostProcessor() + { + $this->assertInstanceOf(Processor::class, $this->datasource->getPostProcessor()); + } +} diff --git a/tests/Halcyon/FileDatasourceTest.php b/tests/Halcyon/FileDatasourceTest.php new file mode 100644 index 000000000..f5514458d --- /dev/null +++ b/tests/Halcyon/FileDatasourceTest.php @@ -0,0 +1,347 @@ +files = new Filesystem; + $this->basePath = __DIR__ . '/../tmp/filedatasource'; + + $this->files->deleteDirectory($this->basePath); + + $this->seedFile('pages/home.htm', 'Home page'); + $this->seedFile('pages/about.htm', 'About page'); + $this->seedFile('pages/nested/deep.htm', 'Deep page'); + $this->seedFile('pages/notes.md', 'Some notes'); + $this->seedFile('content/welcome.md', 'Welcome'); + + // Fixed timestamps keep the mtime assertions deterministic + touch($this->basePath . '/pages/home.htm', strtotime('2019-06-01 12:00:00')); + + $this->datasource = new FileDatasource($this->basePath, $this->files); + } + + public function tearDown(): void + { + $this->files->deleteDirectory($this->basePath); + + parent::tearDown(); + } + + protected function seedFile(string $path, string $content): void + { + $full = $this->basePath . '/' . $path; + + $this->files->makeDirectory(dirname($full), 0777, true, true); + $this->files->put($full, $content); + } + + // + // getAvailablePaths() + // + + public function testGetAvailablePathsReturnsTrueForEveryPath() + { + $paths = $this->datasource->getAvailablePaths(); + + $this->assertNotEmpty($paths); + + // Deliberately `true` rather than a modification time. DbDatasource reports + // timestamps so consumers can skip a database round trip, but resolving a file's + // mtime is a cheap local stat and must stay live -- baking it into the paths cache + // would mean template edits on disk (a deploy, for instance) are not picked up + // until that forever-cached manifest is rebuilt. + foreach ($paths as $path => $value) { + $this->assertTrue($value, "Expected true for {$path}"); + } + } + + public function testGetAvailablePathsListsEveryFileRecursively() + { + $paths = $this->datasource->getAvailablePaths(); + + $this->assertEqualsCanonicalizing([ + 'pages/home.htm', + 'pages/about.htm', + 'pages/nested/deep.htm', + 'pages/notes.md', + 'content/welcome.md', + ], array_keys($paths)); + } + + public function testGetAvailablePathsIsEmptyWhenBasePathIsMissing() + { + $datasource = new FileDatasource($this->basePath . '/nope', $this->files); + + $this->assertSame([], $datasource->getAvailablePaths()); + } + + // + // selectOne() + // + + public function testSelectOneReturnsContentAndMtime() + { + $result = $this->datasource->selectOne('pages', 'home', 'htm'); + + $this->assertSame('home.htm', $result['fileName']); + $this->assertSame('Home page', $result['content']); + $this->assertSame(strtotime('2019-06-01 12:00:00'), $result['mtime']); + } + + public function testSelectOneReadsNestedFiles() + { + $result = $this->datasource->selectOne('pages', 'nested/deep', 'htm'); + + $this->assertSame('Deep page', $result['content']); + } + + public function testSelectOneReturnsNullForMissingFile() + { + $this->assertNull($this->datasource->selectOne('pages', 'nope', 'htm')); + } + + // + // select() + // + + public function testSelectReturnsEveryFileInTheDirectory() + { + $results = collect($this->datasource->select('pages'))->keyBy('fileName'); + + $this->assertEqualsCanonicalizing( + ['home.htm', 'about.htm', 'notes.md', 'nested/deep.htm'], + $results->keys()->all() + ); + $this->assertSame('Home page', $results['home.htm']['content']); + $this->assertSame(strtotime('2019-06-01 12:00:00'), $results['home.htm']['mtime']); + } + + public function testSelectFiltersByExtension() + { + $results = collect($this->datasource->select('pages', ['extensions' => ['md']])); + + $this->assertSame(['notes.md'], $results->pluck('fileName')->all()); + } + + public function testSelectFiltersByFileMatch() + { + $results = collect($this->datasource->select('pages', ['fileMatch' => 'ab*'])); + + $this->assertSame(['about.htm'], $results->pluck('fileName')->all()); + } + + public function testSelectLimitsReturnedColumns() + { + $results = $this->datasource->select('pages', ['columns' => ['fileName']]); + + $this->assertSame(['fileName'], array_keys($results[0])); + } + + public function testSelectTreatsWildcardColumnsAsAllColumns() + { + $results = $this->datasource->select('pages', ['columns' => ['*']]); + + $this->assertEqualsCanonicalizing(['fileName', 'content', 'mtime'], array_keys($results[0])); + } + + public function testSelectReturnsEmptyForMissingDirectory() + { + $this->assertSame([], $this->datasource->select('nope')); + } + + // + // insert() + // + + public function testInsertCreatesTheFileAndReturnsItsSize() + { + $size = $this->datasource->insert('pages', 'created', 'htm', 'Created page'); + + $this->assertSame(12, $size); + $this->assertSame('Created page', $this->files->get($this->basePath . '/pages/created.htm')); + } + + public function testInsertCreatesMissingDirectories() + { + $this->datasource->insert('layouts', 'sub/created', 'htm', 'Created layout'); + + $this->assertSame('Created layout', $this->files->get($this->basePath . '/layouts/sub/created.htm')); + } + + public function testInsertThrowsWhenTheFileAlreadyExists() + { + $this->expectException(FileExistsException::class); + + $this->datasource->insert('pages', 'home', 'htm', 'Replacement'); + } + + // + // update() + // + + public function testUpdateOverwritesContentAndReturnsItsSize() + { + $size = $this->datasource->update('pages', 'home', 'htm', 'Updated page'); + + $this->assertSame(12, $size); + $this->assertSame('Updated page', $this->datasource->selectOne('pages', 'home', 'htm')['content']); + } + + public function testUpdateRenamesTheFile() + { + $this->datasource->update('pages', 'renamed', 'htm', 'Renamed page', 'home', 'htm'); + + $this->assertNull($this->datasource->selectOne('pages', 'home', 'htm')); + $this->assertSame('Renamed page', $this->datasource->selectOne('pages', 'renamed', 'htm')['content']); + } + + public function testUpdateChangesTheExtension() + { + $this->datasource->update('pages', 'home', 'md', 'Now markdown', 'home', 'htm'); + + $this->assertNull($this->datasource->selectOne('pages', 'home', 'htm')); + $this->assertSame('Now markdown', $this->datasource->selectOne('pages', 'home', 'md')['content']); + } + + public function testUpdateAllowsRenamingWhenOnlyTheCaseChanges() + { + $this->datasource->update('pages', 'Home', 'htm', 'Recased page', 'home', 'htm'); + + $this->assertSame('Recased page', $this->datasource->selectOne('pages', 'Home', 'htm')['content']); + } + + public function testUpdateThrowsWhenRenamingOntoAnExistingFile() + { + $this->expectException(FileExistsException::class); + + $this->datasource->update('pages', 'about', 'htm', 'Clobbered', 'home', 'htm'); + } + + // + // delete() + // + + public function testDeleteRemovesTheFile() + { + $this->assertTrue($this->datasource->delete('pages', 'home', 'htm')); + $this->assertNull($this->datasource->selectOne('pages', 'home', 'htm')); + } + + public function testForceDeleteRemovesTheFile() + { + $this->assertTrue($this->datasource->forceDelete('pages', 'home', 'htm')); + $this->assertNull($this->datasource->selectOne('pages', 'home', 'htm')); + } + + public function testDeleteReturnsFalseForMissingFile() + { + $this->assertFalse($this->datasource->delete('pages', 'nope', 'htm')); + } + + // + // lastModified() + // + + public function testLastModifiedReturnsTheFileMtime() + { + $this->assertSame( + strtotime('2019-06-01 12:00:00'), + $this->datasource->lastModified('pages', 'home', 'htm') + ); + } + + public function testLastModifiedReturnsNullForMissingFile() + { + $this->assertNull($this->datasource->lastModified('pages', 'nope', 'htm')); + } + + public function testLastModifiedTracksChangesOnDisk() + { + $before = $this->datasource->lastModified('pages', 'home', 'htm'); + + touch($this->basePath . '/pages/home.htm', strtotime('2020-01-01 12:00:00')); + + // Filesystem mtimes are resolved live, so edits on disk are picked up immediately + $this->assertNotSame($before, $this->datasource->lastModified('pages', 'home', 'htm')); + } + + // + // Path handling + // + + public function testReadingPathsOutsideTheBasePathReturnsNothing() + { + // makeDirectoryPath() throws for paths that escape the base path, but selectOne() + // swallows it along with every other read error, so traversal is denied quietly + $this->assertNull($this->datasource->selectOne('pages', '../../../etc/passwd', 'htm')); + $this->assertNull($this->datasource->lastModified('pages', '../../../etc/passwd', 'htm')); + } + + public function testInsertRejectsPathsOutsideTheBasePath() + { + $this->expectException(InvalidFileNameException::class); + + $this->datasource->insert('pages', '../escaped', 'htm', 'Nope'); + } + + // + // Misc + // + + public function testGetBasePath() + { + $this->assertSame($this->basePath, $this->datasource->getBasePath()); + } + + public function testGetPathsCacheKeyIsScopedToTheBasePath() + { + $this->assertSame('halcyon-datastore-file-' . $this->basePath, $this->datasource->getPathsCacheKey()); + + $other = new FileDatasource($this->basePath . '/other', $this->files); + $this->assertNotSame($this->datasource->getPathsCacheKey(), $other->getPathsCacheKey()); + } + + public function testMakeCacheKeyIsDeterministic() + { + $this->assertSame( + $this->datasource->makeCacheKey('pages/home.htm'), + $this->datasource->makeCacheKey('pages/home.htm') + ); + $this->assertNotSame( + $this->datasource->makeCacheKey('pages/home.htm'), + $this->datasource->makeCacheKey('pages/about.htm') + ); + } + + public function testGetPostProcessor() + { + $this->assertInstanceOf(Processor::class, $this->datasource->getPostProcessor()); + } +}