1 页、T5 deleteBy >1 页) * 计划:.tasks/plans/repository-lang-interceptor-batch-fix.md */ class RepositoryLangInterceptorTest extends TestCase { /** * 生成模拟的 list 项(含 company_id、主键) */ private function makeListItems(string $primaryKey, int $count, int $startId = 1): array { $list = []; for ($i = 0; $i < $count; $i++) { $list[] = [ 'company_id' => 1, $primaryKey => $startId + $i, ]; } return $list; } /** * T1: updateBy ≤1 页 — Mock lists 返回 total_count=N(N≤pageSize)、list 含 N 条;断言 updateLangData 被调用 N 次。 */ public function testUpdateBySinglePageCallsUpdateLangDataNTimes(): void { $primaryKey = 'id'; $callLog = []; $N = 10; $singlePage = $this->makeListItems($primaryKey, $N, 1); $target = $this->getMockBuilder(\stdClass::class) ->addMethods(['lists', 'updateBy']) ->getMock(); $target->table = 'test_table'; $target->module = 'test_module'; $target->primaryKey = $primaryKey; $target->langField = []; $target->expects($this->atLeastOnce()) ->method('lists') ->willReturnOnConsecutiveCalls( ['list' => $singlePage, 'total_count' => $N], ['list' => [], 'total_count' => $N] ); $target->expects($this->once()) ->method('updateBy') ->willReturnCallback(function () use (&$callLog) { $callLog[] = 'targetUpdateBy'; }); $spy = $this->createCommonLangSpy($callLog); $interceptor = new RepositoryLangInterceptor($target); $this->injectCommonLangService($interceptor, $spy); $filter = ['company_id' => 1]; $data = ['name' => 'x']; $interceptor->updateBy($filter, $data); $updateLangDataCount = $this->countInLog($callLog, 'updateLangData'); $this->assertSame($N, $updateLangDataCount, 'updateLangData 应被调用 N 次(≤1 页时 N 条)'); } /** * T2: updateBy 多页 — 第1页100条、第2页50条、第3页0条,共150条。 * 断言:updateLangData 被调用 150 次;先 target->updateBy 再 updateLangData。 */ public function testUpdateByMultiplePagesCallsUpdateLangDataForAllRecordsAndTargetBeforeMultilang(): void { $primaryKey = 'id'; $callLog = []; $page1 = $this->makeListItems($primaryKey, 100, 1); $page2 = $this->makeListItems($primaryKey, 50, 101); $page3 = []; $target = $this->getMockBuilder(\stdClass::class) ->addMethods(['lists', 'updateBy']) ->getMock(); $target->table = 'test_table'; $target->module = 'test_module'; $target->primaryKey = $primaryKey; $target->langField = []; $target->expects($this->atLeastOnce()) ->method('lists') ->willReturnOnConsecutiveCalls( ['list' => $page1, 'total_count' => 150], ['list' => $page2, 'total_count' => 150], ['list' => $page3, 'total_count' => 150] ); $target->expects($this->once()) ->method('updateBy') ->willReturnCallback(function () use (&$callLog) { $callLog[] = 'targetUpdateBy'; }); $spy = $this->createCommonLangSpy($callLog); $interceptor = new RepositoryLangInterceptor($target); $this->injectCommonLangService($interceptor, $spy); $filter = ['company_id' => 1]; $data = ['name' => 'x']; $interceptor->updateBy($filter, $data); $updateLangDataCount = $this->countInLog($callLog, 'updateLangData'); $firstTarget = $this->firstIndexInLog($callLog, 'targetUpdateBy'); $firstMultilang = $this->firstIndexInLog($callLog, 'updateLangData'); $this->assertSame(150, $updateLangDataCount, 'updateLangData 应被调用 150 次(多页总条数)'); $this->assertLessThan($firstMultilang, $firstTarget, '应先执行 target->updateBy 再执行 updateLangData'); } /** * T3: updateBy 参数 — Mock target->lists:断言调用时第二个参数不是 array(即未传入 data),仅首参为 filter。 */ public function testUpdateByListsCalledWithFilterOnlyNotData(): void { $primaryKey = 'id'; $callLog = []; $singlePage = $this->makeListItems($primaryKey, 1, 1); $listsCalls = []; $target = $this->getMockBuilder(\stdClass::class) ->addMethods(['lists', 'updateBy']) ->getMock(); $target->table = 'test_table'; $target->module = 'test_module'; $target->primaryKey = $primaryKey; $target->langField = []; $target->expects($this->atLeastOnce()) ->method('lists') ->willReturnCallback(function ($firstArg, $secondArg = null) use ($singlePage, &$listsCalls) { $listsCalls[] = ['first' => $firstArg, 'second' => $secondArg]; if (count($listsCalls) === 1) { return ['list' => $singlePage, 'total_count' => 1]; } return ['list' => [], 'total_count' => 1]; }); $target->expects($this->once()) ->method('updateBy') ->willReturn(true); $spy = $this->createCommonLangSpy($callLog); $interceptor = new RepositoryLangInterceptor($target); $this->injectCommonLangService($interceptor, $spy); $filter = ['company_id' => 1]; $data = ['name' => 'x']; $interceptor->updateBy($filter, $data); $this->assertNotEmpty($listsCalls, 'lists 应被调用'); foreach ($listsCalls as $call) { $this->assertSame($filter, $call['first'], 'lists 首参应为 filter'); $this->assertFalse(is_array($call['second']), 'lists 第二参数不应为 array(即未传入 data)'); } } /** * T5: deleteBy 多页 — 分页共 200 条。 * 断言:deleteLang 被调用 200 次;先 deleteLang 再 target->deleteBy。 */ public function testDeleteByMultiplePagesCallsDeleteLangForAllRecordsAndMultilangBeforeTarget(): void { $primaryKey = 'id'; $callLog = []; $page1 = $this->makeListItems($primaryKey, 100, 1); $page2 = $this->makeListItems($primaryKey, 100, 101); $page3 = []; $target = $this->getMockBuilder(\stdClass::class) ->addMethods(['lists', 'deleteBy']) ->getMock(); $target->table = 'test_table'; $target->module = 'test_module'; $target->primaryKey = $primaryKey; $target->langField = []; $target->expects($this->atLeastOnce()) ->method('lists') ->willReturnOnConsecutiveCalls( ['list' => $page1, 'total_count' => 200], ['list' => $page2, 'total_count' => 200], ['list' => $page3, 'total_count' => 200] ); $target->expects($this->once()) ->method('deleteBy') ->willReturnCallback(function () use (&$callLog) { $callLog[] = 'targetDeleteBy'; }); $spy = $this->createCommonLangSpy($callLog); $interceptor = new RepositoryLangInterceptor($target); $this->injectCommonLangService($interceptor, $spy); $filter = ['company_id' => 1]; $interceptor->deleteBy($filter); $deleteLangCount = $this->countInLog($callLog, 'deleteLang'); $lastDeleteLang = $this->lastIndexInLog($callLog, 'deleteLang'); $deleteByIndex = $this->firstIndexInLog($callLog, 'targetDeleteBy'); $this->assertSame(200, $deleteLangCount, 'deleteLang 应被调用 200 次(多页总条数)'); $this->assertGreaterThan($lastDeleteLang, $deleteByIndex, '应先执行完所有 deleteLang 再执行 target->deleteBy'); } /** * T4: deleteBy ≤1 页 — Mock lists 返回 1 页;断言 deleteLang 被调用次数 = list 条数。 */ public function testDeleteBySinglePageCallsDeleteLangEqualToListCount(): void { $primaryKey = 'id'; $callLog = []; $listCount = 5; $singlePage = $this->makeListItems($primaryKey, $listCount, 1); $target = $this->getMockBuilder(\stdClass::class) ->addMethods(['lists', 'deleteBy']) ->getMock(); $target->table = 'test_table'; $target->module = 'test_module'; $target->primaryKey = $primaryKey; $target->langField = []; $target->expects($this->atLeastOnce()) ->method('lists') ->willReturnOnConsecutiveCalls( ['list' => $singlePage, 'total_count' => $listCount], ['list' => [], 'total_count' => $listCount] ); $target->expects($this->once()) ->method('deleteBy') ->willReturn(true); $spy = $this->createCommonLangSpy($callLog); $interceptor = new RepositoryLangInterceptor($target); $this->injectCommonLangService($interceptor, $spy); $filter = ['company_id' => 1]; $interceptor->deleteBy($filter); $deleteLangCount = $this->countInLog($callLog, 'deleteLang'); $this->assertSame($listCount, $deleteLangCount, 'deleteLang 被调用次数应等于 list 条数'); } /** * T6: delete 与 deleteBy 一致 — 调用 delete($filter),mock lists 多页;断言 deleteLang 调用次数 = 总条数,且先多语言再 target->delete。 */ public function testDeleteMultiplePagesCallsDeleteLangForAllThenTargetDelete(): void { $primaryKey = 'id'; $callLog = []; $page1 = $this->makeListItems($primaryKey, 80, 1); $page2 = $this->makeListItems($primaryKey, 30, 81); $page3 = []; $totalRows = 110; $target = $this->getMockBuilder(\stdClass::class) ->addMethods(['lists', 'delete']) ->getMock(); $target->table = 'test_table'; $target->module = 'test_module'; $target->primaryKey = $primaryKey; $target->langField = []; $target->expects($this->atLeastOnce()) ->method('lists') ->willReturnOnConsecutiveCalls( ['list' => $page1, 'total_count' => $totalRows], ['list' => $page2, 'total_count' => $totalRows], ['list' => $page3, 'total_count' => $totalRows] ); $target->expects($this->once()) ->method('delete') ->willReturnCallback(function () use (&$callLog) { $callLog[] = 'targetDelete'; }); $spy = $this->createCommonLangSpy($callLog); $interceptor = new RepositoryLangInterceptor($target); $this->injectCommonLangService($interceptor, $spy); $filter = ['company_id' => 1]; $interceptor->delete($filter); $deleteLangCount = $this->countInLog($callLog, 'deleteLang'); $lastDeleteLang = $this->lastIndexInLog($callLog, 'deleteLang'); $deleteIndex = $this->firstIndexInLog($callLog, 'targetDelete'); $this->assertSame($totalRows, $deleteLangCount, 'deleteLang 应被调用总条数次(与 deleteBy 一致)'); $this->assertGreaterThan($lastDeleteLang, $deleteIndex, '应先执行完所有 deleteLang 再执行 target->delete'); } /** * @param array $options 可选 'isDefaultLang' => bool (默认 true), 'langField' => array */ private function createCommonLangSpy(array &$callLog, array $options = []): object { $isDefaultLang = $options['isDefaultLang'] ?? true; return new class($callLog, $isDefaultLang) { private $callLog; private $isDefaultLang; public function __construct(array &$callLog, bool $isDefaultLang = true) { $this->callLog = &$callLog; $this->isDefaultLang = $isDefaultLang; } public function getParamsLang($repository, $data) { return $data; } public function getLangData($data, array $fields) { return ['data' => $data, 'langBag' => []]; } public function isDefaultLang(?string $lang = null): bool { return $this->isDefaultLang; } public function stripLangFieldsFromData(array $data, array $langField): array { $copy = $data; foreach ($langField as $v) { $key = explode('|', $v)[0] ?? ''; if ($key !== '') { unset($copy[$key]); } } return $copy; } public function saveLang(int $companyId, array $langBag, string $table, int $id, string $module, $langue = '') { $this->callLog[] = 'saveLang'; } public function updateLangData(int $companyId, array $langBag, string $tableName, int $dataId, string $module, $langue = '') { $this->callLog[] = 'updateLangData'; } public function deleteLang(int $companyId, string $tableName, int $dataId, string $module) { $this->callLog[] = 'deleteLang'; } }; } private function injectCommonLangService(RepositoryLangInterceptor $interceptor, object $service): void { $ref = new \ReflectionClass($interceptor); $prop = $ref->getProperty('commonLangModService'); $prop->setAccessible(true); $prop->setValue($interceptor, $service); } private function countInLog(array $callLog, string $tag): int { $n = 0; foreach ($callLog as $entry) { if ($entry === $tag) { $n++; } } return $n; } private function firstIndexInLog(array $callLog, string $tag): ?int { foreach ($callLog as $i => $entry) { if ($entry === $tag) { return $i; } } return null; } private function lastIndexInLog(array $callLog, string $tag): ?int { $last = null; foreach ($callLog as $i => $entry) { if ($entry === $tag) { $last = $i; } } return $last; } /** * processByFilterInPages:仅用 filter 分页循环,每页 list 交给回调;list 为空即停止。 * #given mock target->lists 第1页100条、第2页50条、第3页空 * #when 通过反射调用 processByFilterInPages($filter, $processPageList) * #then 回调被调用 2 次,收到的 list 长度分别为 100、50;lists 调用参数为 (filter,'*',page,500) */ public function testProcessByFilterInPagesIteratesUntilListEmptyAndCallsCallbackPerPage(): void { $primaryKey = 'id'; $page1 = $this->makeListItems($primaryKey, 100, 1); $page2 = $this->makeListItems($primaryKey, 50, 101); $page3 = []; $listsCalls = []; $target = $this->getMockBuilder(\stdClass::class) ->addMethods(['lists']) ->getMock(); $target->table = 'test_table'; $target->module = 'test_module'; $target->primaryKey = $primaryKey; $target->langField = []; $target->expects($this->atLeastOnce()) ->method('lists') ->willReturnCallback(function ($filter, $cols, $page, $pageSize) use ($page1, $page2, $page3, &$listsCalls) { $listsCalls[] = ['filter' => $filter, 'cols' => $cols, 'page' => $page, 'pageSize' => $pageSize]; if ($page === 1) { return ['list' => $page1, 'total_count' => 150]; } if ($page === 2) { return ['list' => $page2, 'total_count' => 150]; } return ['list' => $page3, 'total_count' => 150]; }); $interceptor = new RepositoryLangInterceptor($target); $receivedLists = []; $processPageList = function (array $list) use (&$receivedLists) { $receivedLists[] = $list; }; $filter = ['company_id' => 1]; $ref = new \ReflectionClass($interceptor); $method = $ref->getMethod('processByFilterInPages'); $method->setAccessible(true); $method->invoke($interceptor, $filter, $processPageList); $this->assertCount(2, $receivedLists, '回调应被调用 2 次(两页有数据)'); $this->assertCount(100, $receivedLists[0], '第 1 页 list 长度 100'); $this->assertCount(50, $receivedLists[1], '第 2 页 list 长度 50'); $this->assertCount(3, $listsCalls, 'lists 应被调用 3 次(第 3 次返回空结束)'); $this->assertSame($filter, $listsCalls[0]['filter']); $this->assertSame('*', $listsCalls[0]['cols']); $this->assertSame(500, $listsCalls[0]['pageSize']); $this->assertSame(1, $listsCalls[0]['page']); $this->assertSame(2, $listsCalls[1]['page']); $this->assertSame(3, $listsCalls[2]['page']); } /** * TC1(计划 default-lang-config):默认语言 create 时,target->create 收到原样 params(含多语言字段)。 */ public function testCreateWhenDefaultLangPassesParamsUnchangedToTarget(): void { $primaryKey = 'id'; $langField = ['content|json']; $createReceived = null; $target = $this->getMockBuilder(\stdClass::class) ->addMethods(['create']) ->getMock(); $target->table = 'test_table'; $target->module = 'test_module'; $target->primaryKey = $primaryKey; $target->langField = $langField; $target->expects($this->once()) ->method('create') ->willReturnCallback(function (...$args) use (&$createReceived, $primaryKey) { $createReceived = $args; return [$primaryKey => 1, 'company_id' => 1]; }); $callLog = []; $spy = $this->createCommonLangSpy($callLog, ['isDefaultLang' => true]); $interceptor = new RepositoryLangInterceptor($target); $this->injectCommonLangService($interceptor, $spy); $data = ['name' => 'n1', 'content' => 'zh value']; $interceptor->create($data); $this->assertNotNull($createReceived); $this->assertCount(1, $createReceived); $passedData = $createReceived[0]; $this->assertArrayHasKey('content', $passedData, '默认语言时应原样传参,含多语言字段'); $this->assertSame('zh value', $passedData['content']); } /** * TC2(计划 default-lang-config):非默认语言 create 时,target->create 收到的是剔除多语言字段后的 data,原始表不写多语言列。 */ public function testCreateWhenNotDefaultLangPassesStrippedDataToTarget(): void { $primaryKey = 'id'; $langField = ['content|json']; $createReceived = null; $target = $this->getMockBuilder(\stdClass::class) ->addMethods(['create']) ->getMock(); $target->table = 'test_table'; $target->module = 'test_module'; $target->primaryKey = $primaryKey; $target->langField = $langField; $target->expects($this->once()) ->method('create') ->willReturnCallback(function (...$args) use (&$createReceived, $primaryKey) { $createReceived = $args; return [$primaryKey => 1, 'company_id' => 1]; }); $callLog = []; $spy = $this->createCommonLangSpy($callLog, ['isDefaultLang' => false]); $interceptor = new RepositoryLangInterceptor($target); $this->injectCommonLangService($interceptor, $spy); $data = ['name' => 'n1', 'content' => 'lang value']; $interceptor->create($data); $this->assertNotNull($createReceived, 'target->create 应被调用'); $this->assertCount(1, $createReceived, 'create 单参数即 data'); $passedData = $createReceived[0]; $this->assertIsArray($passedData); $this->assertArrayHasKey('name', $passedData); $this->assertSame('n1', $passedData['name']); $this->assertArrayNotHasKey('content', $passedData, '非默认语言时不应向原始表写入多语言字段 content'); } /** * TC2 updateOneBy:非默认语言时 target->updateOneBy 收到 (filter, 剔除多语言字段后的 data)。 */ public function testUpdateOneByWhenNotDefaultLangPassesStrippedDataToTarget(): void { $primaryKey = 'id'; $langField = ['content|json']; $updateOneByReceived = null; $target = $this->getMockBuilder(\stdClass::class) ->addMethods(['updateOneBy']) ->getMock(); $target->table = 'test_table'; $target->module = 'test_module'; $target->primaryKey = $primaryKey; $target->langField = $langField; $target->expects($this->once()) ->method('updateOneBy') ->willReturnCallback(function (...$args) use (&$updateOneByReceived, $primaryKey) { $updateOneByReceived = $args; return [$primaryKey => 1, 'company_id' => 1]; }); $callLog = []; $spy = $this->createCommonLangSpy($callLog, ['isDefaultLang' => false]); $interceptor = new RepositoryLangInterceptor($target); $this->injectCommonLangService($interceptor, $spy); $filter = ['id' => 1]; $data = ['name' => 'u1', 'content' => 'lang value']; $interceptor->updateOneBy($filter, $data); $this->assertNotNull($updateOneByReceived); $this->assertCount(2, $updateOneByReceived, '约定 (filter, data)'); $this->assertSame($filter, $updateOneByReceived[0]); $passedData = $updateOneByReceived[1]; $this->assertArrayHasKey('name', $passedData); $this->assertSame('u1', $passedData['name']); $this->assertArrayNotHasKey('content', $passedData, '非默认语言时不应向原始表写入多语言字段'); } /** * TC5(计划 default-lang-config):非默认语言 updateBy 时,target->updateBy 收到 (filter, 剔除多语言字段后的 data)。 */ public function testUpdateByWhenNotDefaultLangPassesStrippedDataToTarget(): void { $primaryKey = 'id'; $langField = ['content|json']; $updateByReceived = null; $target = $this->getMockBuilder(\stdClass::class) ->addMethods(['updateBy', 'lists']) ->getMock(); $target->table = 'test_table'; $target->module = 'test_module'; $target->primaryKey = $primaryKey; $target->langField = $langField; $target->expects($this->once()) ->method('updateBy') ->willReturnCallback(function ($filter, ...$args) use (&$updateByReceived) { $updateByReceived = [$filter, $args]; return 1; }); $target->expects($this->atLeastOnce()) ->method('lists') ->willReturnOnConsecutiveCalls( ['list' => [], 'total_count' => 0] ); $callLog = []; $spy = $this->createCommonLangSpy($callLog, ['isDefaultLang' => false]); $interceptor = new RepositoryLangInterceptor($target); $this->injectCommonLangService($interceptor, $spy); $filter = ['company_id' => 1]; $data = ['name' => 'u1', 'content' => 'lang value']; $interceptor->updateBy($filter, $data); $this->assertNotNull($updateByReceived); $this->assertCount(2, $updateByReceived); $this->assertSame($filter, $updateByReceived[0]); $params = $updateByReceived[1]; $this->assertCount(1, $params, '约定 params[0] 为 data'); $passedData = $params[0]; $this->assertArrayHasKey('name', $passedData); $this->assertSame('u1', $passedData['name']); $this->assertArrayNotHasKey('content', $passedData, '非默认语言时不应向原始表写入多语言字段'); } /** * TC7 updateOneBy:调用方传入的 data 在 updateOneBy 后未被篡改。 */ public function testUpdateOneByWhenNotDefaultLangDoesNotMutateCallerData(): void { $primaryKey = 'id'; $langField = ['content|json']; $target = $this->getMockBuilder(\stdClass::class) ->addMethods(['updateOneBy']) ->getMock(); $target->table = 'test_table'; $target->module = 'test_module'; $target->primaryKey = $primaryKey; $target->langField = $langField; $target->expects($this->once())->method('updateOneBy')->willReturn([$primaryKey => 1, 'company_id' => 1]); $callLog = []; $spy = $this->createCommonLangSpy($callLog, ['isDefaultLang' => false]); $interceptor = new RepositoryLangInterceptor($target); $this->injectCommonLangService($interceptor, $spy); $filter = ['id' => 1]; $data = ['name' => 'u1', 'content' => 'lang value']; $interceptor->updateOneBy($filter, $data); $this->assertArrayHasKey('content', $data); $this->assertSame('lang value', $data['content'], '调用方 data 的 content 未被篡改'); } /** * TC7(计划 default-lang-config):调用方传入的 data 在 create 后未被篡改,Interceptor 只对副本剔除。 */ public function testCreateWhenNotDefaultLangDoesNotMutateCallerData(): void { $primaryKey = 'id'; $langField = ['content|json']; $target = $this->getMockBuilder(\stdClass::class) ->addMethods(['create']) ->getMock(); $target->table = 'test_table'; $target->module = 'test_module'; $target->primaryKey = $primaryKey; $target->langField = $langField; $target->expects($this->once())->method('create')->willReturn([$primaryKey => 1, 'company_id' => 1]); $callLog = []; $spy = $this->createCommonLangSpy($callLog, ['isDefaultLang' => false]); $interceptor = new RepositoryLangInterceptor($target); $this->injectCommonLangService($interceptor, $spy); $data = ['name' => 'n1', 'content' => 'lang value']; $interceptor->create($data); $this->assertArrayHasKey('content', $data, '调用方 data 应仍含 content'); $this->assertSame('lang value', $data['content'], '调用方 data 的 content 未被篡改'); } /** * T7: 分页循环一致性 — 仅用分页循环时,多页 mock 下断言多语言同步条数正确。 */ public function testPaginationLoopSyncCountConsistency(): void { $primaryKey = 'id'; $callLog = []; $page1 = $this->makeListItems($primaryKey, 40, 1); $page2 = $this->makeListItems($primaryKey, 30, 41); $page3 = $this->makeListItems($primaryKey, 20, 71); $page4 = []; $totalRows = 90; $target = $this->getMockBuilder(\stdClass::class) ->addMethods(['lists', 'deleteBy']) ->getMock(); $target->table = 'test_table'; $target->module = 'test_module'; $target->primaryKey = $primaryKey; $target->langField = []; $target->expects($this->atLeastOnce()) ->method('lists') ->willReturnOnConsecutiveCalls( ['list' => $page1, 'total_count' => $totalRows], ['list' => $page2, 'total_count' => $totalRows], ['list' => $page3, 'total_count' => $totalRows], ['list' => $page4, 'total_count' => $totalRows] ); $target->expects($this->once()) ->method('deleteBy') ->willReturn(true); $spy = $this->createCommonLangSpy($callLog); $interceptor = new RepositoryLangInterceptor($target); $this->injectCommonLangService($interceptor, $spy); $filter = ['company_id' => 1]; $interceptor->deleteBy($filter); $deleteLangCount = $this->countInLog($callLog, 'deleteLang'); $this->assertSame($totalRows, $deleteLangCount, '分页循环下多语言同步条数应等于总条数'); } }