#feature: some update

This commit is contained in:
2026-08-12 18:00:09 +08:00
parent ba916f5ce1
commit 103340536b
39 changed files with 2916 additions and 227 deletions
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
class HostAccessTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['toolbox.admin_host' => 'toolbox.local']);
}
public function test_admin_host_can_open_the_toolbox_menu(): void
{
$response = $this->get('http://toolbox.local/');
$response->assertOk();
$response->assertSee('<admin-dashboard>', false);
}
public function test_ip_host_gets_the_standalone_production_diagnosis_page(): void
{
$response = $this->get('http://192.168.1.20/production-diagnosis');
$response->assertOk();
$response->assertSee('<production-diagnosis>', false);
$response->assertDontSee('<admin-dashboard>', false);
}
public function test_ip_host_cannot_open_other_toolbox_pages(): void
{
$this
->get('http://192.168.1.20/')
->assertNotFound();
$this
->get('http://192.168.1.20/env')
->assertNotFound();
$this
->get('http://192.168.1.20/settings')
->assertNotFound();
}
public function test_ip_host_can_only_call_the_production_diagnosis_api(): void
{
$this
->postJson('http://192.168.1.20/api/production-diagnosis/diagnose', [])
->assertUnprocessable();
$this
->getJson('http://192.168.1.20/api/admin/meta')
->assertNotFound();
}
public function test_unconfigured_hostname_is_rejected(): void
{
$this
->get('http://attacker.example/production-diagnosis')
->assertNotFound();
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace Tests\Feature;
use App\Services\ProductionDiagnosisService;
use Mockery;
use RuntimeException;
use Tests\TestCase;
class ProductionDiagnosisTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['toolbox.admin_host' => 'toolbox.local']);
}
public function test_validation_errors_are_returned_to_ip_clients(): void
{
$this
->postJson('http://192.168.1.20/api/production-diagnosis/diagnose', [])
->assertUnprocessable()
->assertJson([
'success' => false,
'message' => '请求参数验证失败',
]);
}
public function test_successful_diagnosis_response_is_unchanged(): void
{
$result = [
'type' => 'case',
'type_label' => '病例',
'code' => 'C123',
'found' => true,
'entity' => ['patient_name' => '测试患者'],
'checks' => [],
'can_production' => true,
];
$service = Mockery::mock(ProductionDiagnosisService::class);
$service->shouldReceive('diagnose')
->once()
->with('case', 'C123')
->andReturn($result);
$this->app->instance(ProductionDiagnosisService::class, $service);
$this
->postJson('http://192.168.1.20/api/production-diagnosis/diagnose', [
'type' => 'case',
'code' => ' C123 ',
])
->assertOk()
->assertExactJson([
'success' => true,
'data' => $result,
]);
}
public function test_internal_exception_details_are_not_returned(): void
{
$service = Mockery::mock(ProductionDiagnosisService::class);
$service->shouldReceive('diagnose')
->once()
->andThrow(new RuntimeException('SQLSTATE[HY000] secret database detail'));
$this->app->instance(ProductionDiagnosisService::class, $service);
$response = $this->postJson('http://192.168.1.20/api/production-diagnosis/diagnose', [
'type' => 'case',
'code' => 'C123',
]);
$response
->assertInternalServerError()
->assertExactJson([
'success' => false,
'message' => '诊断服务暂不可用,请稍后重试',
]);
$response->assertDontSee('SQLSTATE');
$response->assertDontSee('secret database detail');
}
public function test_ip_client_is_rate_limited_after_thirty_requests_per_minute(): void
{
for ($attempt = 1; $attempt <= 30; $attempt++) {
$this
->withServerVariables(['REMOTE_ADDR' => '192.168.1.50'])
->postJson('http://192.168.1.20/api/production-diagnosis/diagnose', [])
->assertUnprocessable();
}
$this
->withServerVariables(['REMOTE_ADDR' => '192.168.1.50'])
->postJson('http://192.168.1.20/api/production-diagnosis/diagnose', [])
->assertTooManyRequests();
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Tests\Unit;
use App\Enums\CaseLabelBit;
use PHPUnit\Framework\TestCase;
class CaseLabelBitTest extends TestCase
{
public function test_split_returns_each_set_bit(): void
{
$this->assertSame([], CaseLabelBit::split(0));
$this->assertSame([2], CaseLabelBit::split(2));
$this->assertSame([2, 4], CaseLabelBit::split(6));
$this->assertSame([1, 2, 4, 8], CaseLabelBit::split(15));
}
public function test_to_text_describes_stuck_reasons(): void
{
$this->assertSame('无标记', CaseLabelBit::toText(0));
$this->assertSame('新病例订单卡生产', CaseLabelBit::toText(CaseLabelBit::APPLIANCE_NEED_MONEY));
$this->assertSame(
'新病例订单卡生产 / 产品变更卡生产',
CaseLabelBit::toText(CaseLabelBit::APPLIANCE_NEED_MONEY | CaseLabelBit::UPGRADE_NEED_MONEY)
);
}
public function test_unknown_bit_falls_back_to_generic_label(): void
{
$this->assertSame('未知标记位 16', CaseLabelBit::crmLabel(16));
$this->assertStringContainsString('未在 CRM 枚举中定义', CaseLabelBit::description(16));
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Tests\Unit;
use App\Services\DingTalkService;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class DingTalkServiceTest extends TestCase
{
public function test_it_sends_text_to_a_robot_token(): void
{
Http::fake([
'https://oapi.dingtalk.com/robot/send?access_token=report-token' => Http::response([
'errcode' => 0,
]),
]);
$sent = (new DingTalkService)->sendTextToToken('report-token', '日报内容');
$this->assertTrue($sent);
Http::assertSent(fn ($request) => $request->url() === 'https://oapi.dingtalk.com/robot/send?access_token=report-token'
&& $request->data() === [
'msgtype' => 'text',
'text' => ['content' => '日报内容'],
'at' => ['atMobiles' => [], 'isAtAll' => false],
]);
}
public function test_it_reports_a_dingtalk_business_error(): void
{
Http::fake([
'https://oapi.dingtalk.com/robot/send?access_token=invalid-token' => Http::response([
'errcode' => 310000,
'errmsg' => 'invalid token',
]),
]);
$sent = (new DingTalkService)->sendTextToToken('invalid-token', '日报内容');
$this->assertFalse($sent);
}
}
+267
View File
@@ -0,0 +1,267 @@
<?php
namespace Tests\Unit;
use App\Services\ConfigService;
use App\Services\DingTalkService;
use App\Services\ErpRequestReportService;
use Carbon\CarbonImmutable;
use Illuminate\Database\Connection;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use InvalidArgumentException;
use Mockery;
use RuntimeException;
use Tests\TestCase;
class ErpRequestReportServiceTest extends TestCase
{
public function test_it_defaults_to_the_previous_calendar_day(): void
{
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-08-02 16:30:00', 'UTC'));
try {
$database = Mockery::mock(DatabaseManager::class);
$connection = Mockery::mock(Connection::class);
$query = Mockery::mock(Builder::class);
$dingTalkService = Mockery::mock(DingTalkService::class);
$configService = Mockery::mock(ConfigService::class);
$configService->shouldReceive('get')
->once()
->with(ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY)
->andReturn('report-token');
$database->shouldReceive('connection')->once()->with('agentslave')->andReturn($connection);
$connection->shouldReceive('table')->once()->with('request_records')->andReturn($query);
$query->shouldReceive('selectRaw')->once()->andReturnSelf();
$query->shouldReceive('leftJoin')->once()->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.created', '>=', '2026-08-02 00:00:00')->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.created', '<', '2026-08-03 00:00:00')->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.request_uri', 'like', '/openapi/erp/%')->andReturnSelf();
$query->shouldReceive('groupByRaw')->once()->andReturnSelf();
$query->shouldReceive('orderBy')->twice()->andReturnSelf();
$query->shouldReceive('orderByRaw')->once()->andReturnSelf();
$query->shouldReceive('get')->once()->andReturn(collect());
$dingTalkService->shouldReceive('sendTextToToken')
->once()
->with('report-token', "2026-08-02 ERP OpenAPI 请求统计\n无请求记录")
->andReturnTrue();
$result = (new ErpRequestReportService($database, $dingTalkService, $configService))->sendReport();
$this->assertSame('2026-08-02', $result['date']);
$this->assertSame('2026-08-02 00:00:00', $result['from']);
$this->assertSame('2026-08-02 23:59:59', $result['to']);
} finally {
CarbonImmutable::setTestNow();
}
}
public function test_it_sends_the_previous_days_erp_requests_grouped_by_company_and_uri(): void
{
$database = Mockery::mock(DatabaseManager::class);
$connection = Mockery::mock(Connection::class);
$query = Mockery::mock(Builder::class);
$dingTalkService = Mockery::mock(DingTalkService::class);
$configService = Mockery::mock(ConfigService::class);
$configService->shouldReceive('get')
->once()
->with(ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY)
->andReturn('report-token');
$database->shouldReceive('connection')->once()->with('agentslave')->andReturn($connection);
$connection->shouldReceive('table')->once()->with('request_records')->andReturn($query);
$query->shouldReceive('selectRaw')->once()->with("agents.name as agent_name, agents.code as agent_code, SUBSTRING_INDEX(request_records.request_uri, '?', 1) as request_uri, COUNT(*) as request_count")->andReturnSelf();
$query->shouldReceive('leftJoin')->once()->with('agents', 'agents.id', '=', 'request_records.user_id')->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.created', '>=', '2026-08-02 00:00:00')->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.created', '<', '2026-08-03 00:00:00')->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.request_uri', 'like', '/openapi/erp/%')->andReturnSelf();
$query->shouldReceive('groupByRaw')->once()->with("agents.id, agents.name, agents.code, SUBSTRING_INDEX(request_records.request_uri, '?', 1)")->andReturnSelf();
$query->shouldReceive('orderBy')->once()->with('agents.name')->andReturnSelf();
$query->shouldReceive('orderBy')->once()->with('agents.code')->andReturnSelf();
$query->shouldReceive('orderByRaw')->once()->with("SUBSTRING_INDEX(request_records.request_uri, '?', 1)")->andReturnSelf();
$query->shouldReceive('get')->once()->andReturn(collect([
(object) [
'agent_name' => '广州医路精密医疗器械有限公司',
'agent_code' => 'G201704010003',
'request_uri' => '/openapi/erp/deliveries',
'request_count' => 2,
],
(object) [
'agent_name' => '广州医路精密医疗器械有限公司',
'agent_code' => 'G201704010003',
'request_uri' => '/openapi/erp/orders?access_token=secret',
'request_count' => 3,
],
]));
$dingTalkService->shouldReceive('sendTextToToken')
->once()
->with('report-token', "2026-08-02 ERP OpenAPI 请求统计\n\n广州医路精密医疗器械有限公司 G201704010003\n/openapi/erp/deliveries 2次\n/openapi/erp/orders 3次")
->andReturnTrue();
$result = (new ErpRequestReportService($database, $dingTalkService, $configService))
->sendReport('2026-08-02');
$this->assertSame('2026-08-02', $result['date']);
$this->assertSame(5, $result['request_count']);
$this->assertSame(1, $result['company_count']);
}
public function test_it_supports_inclusive_date_ranges(): void
{
$database = Mockery::mock(DatabaseManager::class);
$connection = Mockery::mock(Connection::class);
$query = Mockery::mock(Builder::class);
$dingTalkService = Mockery::mock(DingTalkService::class);
$configService = Mockery::mock(ConfigService::class);
$configService->shouldReceive('get')
->once()
->with(ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY)
->andReturn('report-token');
$database->shouldReceive('connection')->once()->with('agentslave')->andReturn($connection);
$connection->shouldReceive('table')->once()->with('request_records')->andReturn($query);
$query->shouldReceive('selectRaw')->once()->andReturnSelf();
$query->shouldReceive('leftJoin')->once()->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.created', '>=', '2026-08-01 00:00:00')->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.created', '<', '2026-08-08 00:00:00')->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.request_uri', 'like', '/openapi/erp/%')->andReturnSelf();
$query->shouldReceive('groupByRaw')->once()->andReturnSelf();
$query->shouldReceive('orderBy')->twice()->andReturnSelf();
$query->shouldReceive('orderByRaw')->once()->andReturnSelf();
$query->shouldReceive('get')->once()->andReturn(collect());
$dingTalkService->shouldReceive('sendTextToToken')
->once()
->with('report-token', "2026-08-01 ~ 2026-08-07 ERP OpenAPI 请求统计\n无请求记录")
->andReturnTrue();
$result = (new ErpRequestReportService($database, $dingTalkService, $configService))
->sendReport(null, '2026-08-01', '2026-08-07');
$this->assertSame('2026-08-01 ~ 2026-08-07', $result['date']);
$this->assertSame('2026-08-01 00:00:00', $result['from']);
$this->assertSame('2026-08-07 23:59:59', $result['to']);
}
public function test_it_supports_inclusive_datetime_ranges(): void
{
$database = Mockery::mock(DatabaseManager::class);
$connection = Mockery::mock(Connection::class);
$query = Mockery::mock(Builder::class);
$dingTalkService = Mockery::mock(DingTalkService::class);
$configService = Mockery::mock(ConfigService::class);
$configService->shouldReceive('get')
->once()
->with(ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY)
->andReturn('report-token');
$database->shouldReceive('connection')->once()->with('agentslave')->andReturn($connection);
$connection->shouldReceive('table')->once()->with('request_records')->andReturn($query);
$query->shouldReceive('selectRaw')->once()->andReturnSelf();
$query->shouldReceive('leftJoin')->once()->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.created', '>=', '2026-08-02 08:00:00')->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.created', '<', '2026-08-02 18:00:01')->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.request_uri', 'like', '/openapi/erp/%')->andReturnSelf();
$query->shouldReceive('groupByRaw')->once()->andReturnSelf();
$query->shouldReceive('orderBy')->twice()->andReturnSelf();
$query->shouldReceive('orderByRaw')->once()->andReturnSelf();
$query->shouldReceive('get')->once()->andReturn(collect());
$dingTalkService->shouldReceive('sendTextToToken')
->once()
->with('report-token', "2026-08-02 08:00:00 ~ 2026-08-02 18:00:00 ERP OpenAPI 请求统计\n无请求记录")
->andReturnTrue();
$result = (new ErpRequestReportService($database, $dingTalkService, $configService))
->sendReport(null, '2026-08-02 08:00:00', '2026-08-02 18:00:00');
$this->assertSame('2026-08-02 08:00:00 ~ 2026-08-02 18:00:00', $result['date']);
}
public function test_it_rejects_date_mixed_with_from_to(): void
{
$database = Mockery::mock(DatabaseManager::class);
$dingTalkService = Mockery::mock(DingTalkService::class);
$configService = Mockery::mock(ConfigService::class);
$configService->shouldReceive('get')
->once()
->with(ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY)
->andReturn('report-token');
$database->shouldNotReceive('connection');
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('--date 不能与 --from/--to 同时使用');
(new ErpRequestReportService($database, $dingTalkService, $configService))
->sendReport('2026-08-02', '2026-08-01', '2026-08-07');
}
public function test_it_requires_a_configured_dingtalk_token_before_querying(): void
{
$database = Mockery::mock(DatabaseManager::class);
$dingTalkService = Mockery::mock(DingTalkService::class);
$configService = Mockery::mock(ConfigService::class);
$configService->shouldReceive('get')
->once()
->with(ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY)
->andReturn(null);
$database->shouldNotReceive('connection');
$dingTalkService->shouldNotReceive('sendTextToToken');
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('未配置 ERP 请求日报的钉钉机器人 Token');
(new ErpRequestReportService($database, $dingTalkService, $configService))
->sendReport('2026-08-02');
}
public function test_it_splits_large_reports_into_safe_dingtalk_messages(): void
{
$database = Mockery::mock(DatabaseManager::class);
$connection = Mockery::mock(Connection::class);
$query = Mockery::mock(Builder::class);
$dingTalkService = Mockery::mock(DingTalkService::class);
$configService = Mockery::mock(ConfigService::class);
$sentMessages = [];
$configService->shouldReceive('get')
->once()
->with(ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY)
->andReturn('report-token');
$database->shouldReceive('connection')->once()->with('agentslave')->andReturn($connection);
$connection->shouldReceive('table')->once()->with('request_records')->andReturn($query);
$query->shouldReceive('selectRaw')->once()->andReturnSelf();
$query->shouldReceive('leftJoin')->once()->andReturnSelf();
$query->shouldReceive('where')->times(3)->andReturnSelf();
$query->shouldReceive('groupByRaw')->once()->andReturnSelf();
$query->shouldReceive('orderBy')->twice()->andReturnSelf();
$query->shouldReceive('orderByRaw')->once()->andReturnSelf();
$query->shouldReceive('get')->once()->andReturn(collect(range(1, 300))->map(
fn (int $index) => (object) [
'agent_name' => '广州医路精密医疗器械有限公司',
'agent_code' => 'G201704010003',
'request_uri' => '/openapi/erp/'.str_pad((string) $index, 100, 'x'),
'request_count' => 1,
]
));
$dingTalkService->shouldReceive('sendTextToToken')
->atLeast()->once()
->withArgs(function (string $token, string $message) use (&$sentMessages): bool {
$sentMessages[] = $message;
return $token === 'report-token';
})
->andReturnTrue();
(new ErpRequestReportService($database, $dingTalkService, $configService))->sendReport('2026-08-02');
$this->assertGreaterThan(1, count($sentMessages));
$this->assertContainsOnly('string', $sentMessages);
$this->assertTrue(collect($sentMessages)->every(fn (string $message) => strlen($message) <= 18_000));
$this->assertStringContainsString('/openapi/erp/'.str_pad('300', 100, 'x').' 1次', implode("\n", $sentMessages));
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace Tests\Unit;
use App\Clients\JenkinsClient;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class JenkinsClientTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'jenkins.host' => 'https://jenkins.example.com',
'jenkins.username' => 'test-user',
'jenkins.api_token' => 'test-token',
'jenkins.timeout' => 30,
]);
}
public function test_parameterized_build_posts_form_json_parameters_and_returns_queue_url(): void
{
Http::fake([
'https://jenkins.example.com/job/deploy/api/json' => Http::response([
'nextBuildNumber' => 42,
]),
'https://jenkins.example.com/crumbIssuer/api/json' => Http::response([
'crumbRequestField' => 'Jenkins-Crumb',
'crumb' => 'test-crumb',
]),
'https://jenkins.example.com/job/deploy/build?delay=0sec' => Http::response('', 201, [
'Location' => 'https://jenkins.example.com/queue/item/123/',
]),
]);
$result = app(JenkinsClient::class)->triggerBuild('deploy', [
'project' => 'portal',
'branchName' => 'release/1.0',
]);
$this->assertTrue($result['success']);
$this->assertSame('https://jenkins.example.com/queue/item/123/', $result['queue_url']);
$this->assertSame(42, $result['build_number']);
Http::assertSent(function ($request) {
parse_str($request->body(), $form);
$payload = json_decode((string) ($form['json'] ?? ''), true);
$parameters = collect($payload['parameter'] ?? [])->pluck('value', 'name');
return $request->method() === 'POST'
&& $request->url() === 'https://jenkins.example.com/job/deploy/build?delay=0sec'
&& $parameters['project'] === 'portal'
&& $parameters['branchName'] === 'release/1.0';
});
}
public function test_cancel_build_with_queue_url_cancels_pending_queue_item(): void
{
Http::fake([
'https://jenkins.example.com/queue/item/123/api/json' => Http::response([
'why' => 'In the quiet period',
]),
'https://jenkins.example.com/crumbIssuer/api/json' => Http::response([
'crumbRequestField' => 'Jenkins-Crumb',
'crumb' => 'test-crumb',
]),
'https://jenkins.example.com/queue/cancelItem?id=123' => Http::response('', 200),
]);
$result = app(JenkinsClient::class)->cancelBuild('deploy', 'https://jenkins.example.com/queue/item/123/');
$this->assertTrue($result['success']);
$this->assertTrue($result['cancelled_queue']);
Http::assertSent(function ($request) {
return $request->method() === 'POST'
&& $request->url() === 'https://jenkins.example.com/queue/cancelItem?id=123';
});
}
public function test_cancel_build_without_queue_url_cancels_matching_queued_job(): void
{
Http::fake([
'https://jenkins.example.com/queue/api/json' => Http::response([
'items' => [
[
'id' => 456,
'task' => [
'fullName' => 'deploy',
'name' => 'deploy',
],
],
],
]),
'https://jenkins.example.com/crumbIssuer/api/json' => Http::response([
'crumbRequestField' => 'Jenkins-Crumb',
'crumb' => 'test-crumb',
]),
'https://jenkins.example.com/queue/cancelItem?id=456' => Http::response('', 200),
]);
$result = app(JenkinsClient::class)->cancelBuild('deploy', null, 42);
$this->assertTrue($result['success']);
$this->assertTrue($result['cancelled_queue']);
Http::assertSent(function ($request) {
return $request->method() === 'POST'
&& $request->url() === 'https://jenkins.example.com/queue/cancelItem?id=456';
});
}
}
+31
View File
@@ -5,6 +5,7 @@ namespace Tests\Unit;
use App\Services\JiraService;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use JiraRestApi\Project\ProjectService;
use Tests\TestCase;
class JiraServiceTest extends TestCase
@@ -263,6 +264,25 @@ class JiraServiceTest extends TestCase
$this->assertEquals('2.70.0.0', $method->invoke($this->jiraService, '2.69.0.0'));
}
public function test_upcoming_release_version_falls_back_to_next_minor_when_jira_versions_are_not_maintained()
{
$projectService = $this->createMock(ProjectService::class);
$projectService->method('getVersions')->with('TP')->willReturn(new \ArrayObject([
(object) ['name' => '1.34.0.0', 'released' => false],
(object) ['name' => '1.37.0.0', 'released' => false],
]));
$reflection = new \ReflectionClass($this->jiraService);
$property = $reflection->getProperty('projectService');
$property->setValue($this->jiraService, $projectService);
$this->assertSame([
'version' => '1.46.0.0',
'description' => null,
'release_date' => null,
], $this->jiraService->getUpcomingReleaseVersion('TP', '1.45.0.0'));
}
public function test_test_mail_template_defaults_use_next_versions()
{
$defaults = $this->jiraService->getTestMailTemplateDefaults();
@@ -270,6 +290,17 @@ class JiraServiceTest extends TestCase
$this->assertEquals('2.70.0.0', $defaults['container_groups']['agent']['default_version']);
$this->assertEquals('2.65.0.0', $defaults['container_groups']['portal']['default_version']);
$this->assertEquals('1.43.0.0', $defaults['container_groups']['portal-ticket']['default_version']);
$this->assertEquals('1.12.0.0', $defaults['container_groups']['mono']['default_version']);
$this->assertSame('mono', array_key_last($defaults['container_groups']));
$this->assertEquals(
['portal-mono-be-aplct', 'portal-mono-be-web'],
array_column($defaults['container_groups']['mono']['containers'], 'name')
);
$this->assertEquals(
['中国', '中国'],
array_column($defaults['container_groups']['mono']['containers'], 'location')
);
$this->assertFalse($defaults['container_groups']['mono']['database_enabled']);
}
public function test_extract_bug_stage_from_labels()