createMock(\medoo::class); $mockDb->expects($this->once()) ->method('get') ->with('pp_articles', '*', ['id' => 7]) ->willReturn(['id' => 7, 'status' => 1]); $mockDb->expects($this->exactly(4)) ->method('select') ->willReturnOnConsecutiveCalls( [ ['lang_id' => 'pl', 'title' => 'Artykul'], ['lang_id' => 'en', 'title' => 'Article'], ], [ ['id' => 10, 'src' => '/img/a.jpg'] ], [ ['id' => 20, 'src' => '/files/a.pdf'] ], [1, 2] ); $repository = new ArticleRepository($mockDb); $article = $repository->find(7); $this->assertIsArray($article); $this->assertEquals(7, $article['id']); $this->assertArrayHasKey('languages', $article); $this->assertEquals('Artykul', $article['languages']['pl']['title']); $this->assertCount(1, $article['images']); $this->assertCount(1, $article['files']); $this->assertEquals([1, 2], $article['pages']); } public function testFindReturnsNullWhenArticleDoesNotExist(): void { $mockDb = $this->createMock(\medoo::class); $mockDb->expects($this->once()) ->method('get') ->with('pp_articles', '*', ['id' => 999]) ->willReturn(false); $mockDb->expects($this->never())->method('select'); $repository = new ArticleRepository($mockDb); $article = $repository->find(999); $this->assertNull($article); } public function testDeleteNonassignedFilesDeletesDbRows(): void { $mockDb = $this->createMock(\medoo::class); $mockDb->expects($this->once()) ->method('select') ->with('pp_articles_files', '*', ['article_id' => null]) ->willReturn([ ['id' => 1, 'src' => '/this/path/does/not/exist-file.tmp'] ]); $mockDb->expects($this->once()) ->method('delete') ->with('pp_articles_files', ['article_id' => null]); $repository = new ArticleRepository($mockDb); $repository->deleteNonassignedFiles(); $this->assertTrue(true); } public function testDeleteNonassignedImagesDeletesDbRows(): void { $mockDb = $this->createMock(\medoo::class); $mockDb->expects($this->once()) ->method('select') ->with('pp_articles_images', '*', ['article_id' => null]) ->willReturn([ ['id' => 1, 'src' => '/this/path/does/not/exist-image.tmp'] ]); $mockDb->expects($this->once()) ->method('delete') ->with('pp_articles_images', ['article_id' => null]); $repository = new ArticleRepository($mockDb); $repository->deleteNonassignedImages(); $this->assertTrue(true); } private function getSampleData(): array { return [ 'title' => ['pl' => 'Testowy artykul', 'en' => 'Test article'], 'main_image' => ['pl' => '/img/pl.jpg', 'en' => ''], 'entry' => ['pl' => 'Wstep', 'en' => 'Entry'], 'text' => ['pl' => 'Tresc', 'en' => 'Content'], 'table_of_contents' => ['pl' => '', 'en' => ''], 'status' => 'on', 'show_title' => 'on', 'show_table_of_contents' => '', 'show_date_add' => 'on', 'date_add' => '', 'show_date_modify' => '', 'date_modify' => '', 'seo_link' => ['pl' => 'testowy-artykul', 'en' => 'test-article'], 'meta_title' => ['pl' => 'Meta PL', 'en' => ''], 'meta_description' => ['pl' => '', 'en' => ''], 'meta_keywords' => ['pl' => '', 'en' => ''], 'layout_id' => '2', 'pages' => ['1', '3'], 'noindex' => ['pl' => '', 'en' => 'on'], 'repeat_entry' => '', 'copy_from' => ['pl' => '', 'en' => ''], 'social_icons' => 'on', 'block_direct_access' => ['pl' => '', 'en' => ''], ]; } public function testSaveCreatesNewArticle(): void { $mockDb = $this->createMock(\medoo::class); $data = $this->getSampleData(); $insertCalls = []; $mockDb->method('insert') ->willReturnCallback(function ($table, $row) use (&$insertCalls) { $insertCalls[] = ['table' => $table, 'row' => $row]; return true; }); $mockDb->expects($this->once()) ->method('id') ->willReturn(42); $mockDb->method('select')->willReturn([]); $mockDb->method('max')->willReturn(5); $repository = new ArticleRepository($mockDb); $result = $repository->save(0, $data, 1); $this->assertEquals(42, $result); // Verify article insert $articleInsert = $insertCalls[0]; $this->assertEquals('pp_articles', $articleInsert['table']); $this->assertEquals(1, $articleInsert['row']['status']); $this->assertEquals(1, $articleInsert['row']['show_title']); $this->assertEquals(2, $articleInsert['row']['layout_id']); $this->assertArrayHasKey('date_add', $articleInsert['row']); } public function testSaveReturnsZeroWhenInsertFails(): void { $mockDb = $this->createMock(\medoo::class); $mockDb->method('insert'); $mockDb->method('id')->willReturn(null); $repository = new ArticleRepository($mockDb); $result = $repository->save(0, $this->getSampleData(), 1); $this->assertEquals(0, $result); } public function testSaveUpdatesExistingArticle(): void { $mockDb = $this->createMock(\medoo::class); $data = $this->getSampleData(); $updateCalls = []; $mockDb->method('update') ->willReturnCallback(function ($table, $row, $where = null) use (&$updateCalls) { $updateCalls[] = ['table' => $table, 'row' => $row, 'where' => $where]; return true; }); $mockDb->method('get')->willReturn(99); $mockDb->method('select')->willReturn([]); $mockDb->method('max')->willReturn(0); $mockDb->method('insert')->willReturn(true); $repository = new ArticleRepository($mockDb); $result = $repository->save(10, $data, 1); $this->assertEquals(10, $result); // Verify article update $articleUpdate = $updateCalls[0]; $this->assertEquals('pp_articles', $articleUpdate['table']); $this->assertEquals(1, $articleUpdate['row']['status']); $this->assertArrayNotHasKey('date_add', $articleUpdate['row']); $this->assertEquals(['id' => 10], $articleUpdate['where']); } public function testSaveTranslationsInsertsForNewArticle(): void { $mockDb = $this->createMock(\medoo::class); $data = $this->getSampleData(); // 1 insert for pp_articles + 2 inserts for translations (pl, en) + 2 inserts for pages $insertCalls = []; $mockDb->method('insert') ->willReturnCallback(function ($table, $row) use (&$insertCalls) { $insertCalls[] = ['table' => $table, 'row' => $row]; return true; }); $mockDb->method('id')->willReturn(50); $mockDb->method('select')->willReturn([]); $mockDb->method('max')->willReturn(0); $repository = new ArticleRepository($mockDb); $repository->save(0, $data, 1); $langInserts = array_filter($insertCalls, function ($c) { return $c['table'] === 'pp_articles_langs'; }); $this->assertCount(2, $langInserts); $plInsert = array_values(array_filter($langInserts, function ($c) { return $c['row']['lang_id'] === 'pl'; }))[0]['row']; $this->assertEquals(50, $plInsert['article_id']); $this->assertEquals('Testowy artykul', $plInsert['title']); $this->assertEquals('/img/pl.jpg', $plInsert['main_image']); } public function testSaveTranslationsUpsertsForExistingArticle(): void { $mockDb = $this->createMock(\medoo::class); $data = $this->getSampleData(); // get returns translation ID for 'pl', null for 'en' $mockDb->method('get') ->willReturnOnConsecutiveCalls(100, null); $updateCalls = []; $mockDb->method('update') ->willReturnCallback(function ($table, $row, $where = null) use (&$updateCalls) { $updateCalls[] = ['table' => $table, 'row' => $row, 'where' => $where]; return true; }); $insertCalls = []; $mockDb->method('insert') ->willReturnCallback(function ($table, $row) use (&$insertCalls) { $insertCalls[] = ['table' => $table, 'row' => $row]; return true; }); $mockDb->method('select')->willReturn([]); $mockDb->method('max')->willReturn(0); $repository = new ArticleRepository($mockDb); $repository->save(10, $data, 1); // pl should be updated (translation_id=100) $langUpdates = array_filter($updateCalls, function ($c) { return $c['table'] === 'pp_articles_langs'; }); $this->assertCount(1, $langUpdates); // en should be inserted (no existing translation) $langInserts = array_filter($insertCalls, function ($c) { return $c['table'] === 'pp_articles_langs'; }); $this->assertCount(1, $langInserts); } public function testSavePagesForNewArticle(): void { $mockDb = $this->createMock(\medoo::class); $data = $this->getSampleData(); $data['pages'] = ['5', '8']; $insertCalls = []; $mockDb->method('insert') ->willReturnCallback(function ($table, $row) use (&$insertCalls) { $insertCalls[] = ['table' => $table, 'row' => $row]; return true; }); $mockDb->method('id')->willReturn(60); $mockDb->method('select')->willReturn([]); $mockDb->method('max')->willReturn(10); $repository = new ArticleRepository($mockDb); $repository->save(0, $data, 1); $pageInserts = array_filter($insertCalls, function ($c) { return $c['table'] === 'pp_articles_pages'; }); $this->assertCount(2, $pageInserts); $pageIds = array_map(function ($c) { return $c['row']['page_id']; }, array_values($pageInserts)); $this->assertContains(5, $pageIds); $this->assertContains(8, $pageIds); } public function testSaveDeletesMarkedImagesOnUpdate(): void { $mockDb = $this->createMock(\medoo::class); $data = $this->getSampleData(); $data['pages'] = null; $mockDb->method('update')->willReturn(true); $mockDb->method('get')->willReturn(null); $mockDb->method('max')->willReturn(0); $selectCalls = 0; $mockDb->method('select') ->willReturnCallback(function ($table, $columns, $where) use (&$selectCalls) { $selectCalls++; // Return marked images for deletion query if ($table === 'pp_articles_images' && isset($where['AND']['to_delete'])) { return [['id' => 1, 'src' => '/nonexistent/path/img.jpg']]; } if ($table === 'pp_articles_files' && isset($where['AND']['to_delete'])) { return [['id' => 2, 'src' => '/nonexistent/path/file.pdf']]; } return []; }); $deleteCalls = []; $mockDb->method('delete') ->willReturnCallback(function ($table, $where) use (&$deleteCalls) { $deleteCalls[] = ['table' => $table, 'where' => $where]; return true; }); $mockDb->method('insert')->willReturn(true); $repository = new ArticleRepository($mockDb); $repository->save(15, $data, 1); $imageDeletes = array_filter($deleteCalls, function ($c) { return $c['table'] === 'pp_articles_images'; }); $fileDeletes = array_filter($deleteCalls, function ($c) { return $c['table'] === 'pp_articles_files'; }); $this->assertNotEmpty($imageDeletes); $this->assertNotEmpty($fileDeletes); } public function testSaveGalleryOrderUpdatesImageOrder(): void { $mockDb = $this->createMock(\medoo::class); $mockDb->expects($this->exactly(3)) ->method('update') ->withConsecutive( [ 'pp_articles_images', ['o' => 0], ['AND' => ['article_id' => 12, 'id' => 50]] ], [ 'pp_articles_images', ['o' => 1], ['AND' => ['article_id' => 12, 'id' => 51]] ], [ 'pp_articles_images', ['o' => 2], ['AND' => ['article_id' => 12, 'id' => 52]] ] ) ->willReturn(true); $repository = new ArticleRepository($mockDb); $result = $repository->saveGalleryOrder(12, '50;51;52'); $this->assertTrue($result); } public function testSaveGalleryOrderSkipsEmptyValues(): void { $mockDb = $this->createMock(\medoo::class); $mockDb->expects($this->once()) ->method('update') ->with( 'pp_articles_images', ['o' => 0], ['AND' => ['article_id' => 7, 'id' => 99]] ) ->willReturn(true); $repository = new ArticleRepository($mockDb); $result = $repository->saveGalleryOrder(7, ';99;'); $this->assertTrue($result); } public function testArchiveSetsStatusToMinusOne(): void { $mockDb = $this->createMock(\medoo::class); $mockDb->expects($this->once()) ->method('update') ->with('pp_articles', ['status' => -1], ['id' => 25]) ->willReturn(true); $repository = new ArticleRepository($mockDb); $result = $repository->archive(25); $this->assertTrue($result); } public function testArchiveReturnsFalseWhenUpdateFails(): void { $mockDb = $this->createMock(\medoo::class); $mockDb->expects($this->once()) ->method('update') ->with('pp_articles', ['status' => -1], ['id' => 999]) ->willReturn(false); $repository = new ArticleRepository($mockDb); $result = $repository->archive(999); $this->assertFalse($result); } public function testListForAdminWhitelistsSortAndDirection(): void { $mockDb = $this->createMock(\medoo::class); $queries = []; $mockDb->method('query') ->willReturnCallback(function ($sql, $params = []) use (&$queries) { $queries[] = ['sql' => $sql, 'params' => $params]; if (strpos($sql, 'COUNT(0)') !== false) { return new class { public function fetchAll() { return [[1]]; } }; } return new class { public function fetchAll() { return [[ 'id' => 1, 'date_add' => '2020-01-01 00:00:00', 'date_modify' => '2020-01-01 00:00:00', 'status' => 1, 'title' => 'A', 'user' => 'admin', ]]; } }; }); $repository = new ArticleRepository($mockDb); $repository->listForAdmin( [], 'date_add DESC; DROP TABLE pp_articles; --', 'DESC; DELETE FROM pp_users; --', 1, 100000 ); $this->assertCount(2, $queries); $dataSql = $queries[1]['sql']; $this->assertMatchesRegularExpression('/ORDER BY\s+pa\.date_add\s+DESC,\s+pa\.id\s+DESC/i', $dataSql); $this->assertStringNotContainsString('DROP TABLE', $dataSql); $this->assertStringNotContainsString('DELETE FROM pp_users', $dataSql); $this->assertMatchesRegularExpression('/LIMIT\s+100\s+OFFSET\s+0/i', $dataSql); } public function testListForAdminUsesBoundParamsForTitleFilter(): void { $mockDb = $this->createMock(\medoo::class); $queries = []; $attack = "' OR 1=1 --"; $mockDb->method('query') ->willReturnCallback(function ($sql, $params = []) use (&$queries) { $queries[] = ['sql' => $sql, 'params' => $params]; if (strpos($sql, 'COUNT(0)') !== false) { return new class { public function fetchAll() { return [[0]]; } }; } return new class { public function fetchAll() { return []; } }; }); $repository = new ArticleRepository($mockDb); $repository->listForAdmin(['title' => $attack], 'title', 'ASC', 1, 15); $this->assertCount(2, $queries); $countSql = $queries[0]['sql']; $countParams = $queries[0]['params']; $this->assertStringContainsString('LIKE :title', $countSql); $this->assertStringNotContainsString($attack, $countSql); $this->assertArrayHasKey(':title', $countParams); $this->assertSame('%' . $attack . '%', $countParams[':title']); } }