Files
ars-backend/tests/Feature/ChatSessionTest.php
ROOG 318571a6d9 main: 增强会话功能,支持归档与消息检索
- 添加会话归档接口及相关服务逻辑,并确保幂等性
- 实现单条消息获取接口,校验消息所属会话
- 增加 SSE 增量推送与实时消息订阅功能
- 提供相关的测试用例覆盖新功能
- 更新接口文档,完善 OpenAPI 规范,新增多项示例
2025-12-14 21:58:05 +08:00

310 lines
11 KiB
PHP

<?php
namespace Tests\Feature;
use App\Enums\ChatSessionStatus;
use App\Models\User;
use App\Services\ChatService;
use Illuminate\Support\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Redis;
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
use Tests\TestCase;
class ChatSessionTest extends TestCase
{
use RefreshDatabase;
private function authHeader(User $user): array
{
return ['Authorization' => 'Bearer '.JWTAuth::fromUser($user)];
}
public function test_append_messages_sequential_seq_and_last_seq(): void
{
$service = app(ChatService::class);
$session = $service->createSession('Test Session');
$messages = collect(range(1, 20))->map(function ($i) use ($service, $session) {
return $service->appendMessage([
'session_id' => $session->session_id,
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'msg '.$i,
]);
});
$seqs = $messages->pluck('seq')->all();
$this->assertEquals(range(1, 20), $seqs);
$session->refresh();
$this->assertEquals(20, $session->last_seq);
}
public function test_dedupe_returns_existing_message(): void
{
$service = app(ChatService::class);
$session = $service->createSession('Test Session');
$first = $service->appendMessage([
'session_id' => $session->session_id,
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'hello',
'dedupe_key' => 'k1',
]);
$second = $service->appendMessage([
'session_id' => $session->session_id,
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'hello again',
'dedupe_key' => 'k1',
]);
$this->assertEquals($first->message_id, $second->message_id);
$this->assertEquals($first->seq, $second->seq);
$this->assertCount(1, $session->messages()->get());
}
public function test_closed_session_blocks_append_except_whitelisted(): void
{
$service = app(ChatService::class);
$session = $service->createSession('Test Session');
$session->update(['status' => ChatSessionStatus::CLOSED]);
$this->expectExceptionMessage('Session is closed');
$service->appendMessage([
'session_id' => $session->session_id,
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'blocked',
]);
}
public function test_http_append_and_list_messages(): void
{
$user = User::factory()->create();
$headers = $this->authHeader($user);
$createSession = $this->withHeaders($headers)->postJson('/api/sessions', [
'session_name' => 'API Session',
])->assertCreated();
$sessionId = $createSession->json('data.session_id');
$append = $this->withHeaders($headers)->postJson("/api/sessions/{$sessionId}/messages", [
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'hello api',
])->assertCreated();
$this->assertEquals(1, $append->json('data.seq'));
$list = $this->withHeaders($headers)->getJson("/api/sessions/{$sessionId}/messages?after_seq=0&limit=10")
->assertOk();
$this->assertEquals(1, $list->json('data.0.seq'));
$this->assertEquals('hello api', $list->json('data.0.content'));
}
public function test_session_list_sorted_and_last_message_preview(): void
{
$user = User::factory()->create();
$headers = $this->authHeader($user);
$service = app(ChatService::class);
Carbon::setTestNow('2025-01-01 00:00:00');
$s1 = $service->createSession('First');
$service->appendMessage([
'session_id' => $s1->session_id,
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'hello first',
]);
Carbon::setTestNow('2025-01-01 00:00:10');
$s2 = $service->createSession('Second');
$service->appendMessage([
'session_id' => $s2->session_id,
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'hello second',
]);
Carbon::setTestNow();
$resp = $this->withHeaders($headers)->getJson('/api/sessions?per_page=10')
->assertOk();
$this->assertEquals($s2->session_id, $resp->json('data.0.session_id'));
$this->assertEquals('hello second', $resp->json('data.0.last_message_preview'));
$this->assertEquals('hello first', $resp->json('data.1.last_message_preview'));
$this->assertNotNull($resp->json('data.0.last_message_at'));
}
public function test_session_list_filters_status_and_query(): void
{
$user = User::factory()->create();
$headers = $this->authHeader($user);
$service = app(ChatService::class);
$open = $service->createSession('Alpha');
$locked = $service->createSession('Beta');
$locked->update(['status' => ChatSessionStatus::LOCKED]);
$closed = $service->createSession('Gamma');
$closed->update(['status' => ChatSessionStatus::CLOSED]);
$respStatus = $this->withHeaders($headers)->getJson('/api/sessions?status=LOCKED')
->assertOk();
$this->assertCount(1, $respStatus->json('data'));
$this->assertEquals($locked->session_id, $respStatus->json('data.0.session_id'));
$respQuery = $this->withHeaders($headers)->getJson('/api/sessions?q=Alpha')
->assertOk();
$this->assertCount(1, $respQuery->json('data'));
$this->assertEquals($open->session_id, $respQuery->json('data.0.session_id'));
}
public function test_patch_updates_session_name(): void
{
$user = User::factory()->create();
$headers = $this->authHeader($user);
$service = app(ChatService::class);
$session = $service->createSession('Old Name');
$resp = $this->withHeaders($headers)->patchJson("/api/sessions/{$session->session_id}", [
'session_name' => 'New Name',
])->assertOk();
$this->assertEquals('New Name', $resp->json('data.session_name'));
}
public function test_patch_updates_status_transitions(): void
{
$user = User::factory()->create();
$headers = $this->authHeader($user);
$service = app(ChatService::class);
$session = $service->createSession('Status Session');
$this->withHeaders($headers)->patchJson("/api/sessions/{$session->session_id}", [
'status' => ChatSessionStatus::LOCKED,
])->assertOk()->assertJsonFragment(['status' => ChatSessionStatus::LOCKED]);
$this->withHeaders($headers)->patchJson("/api/sessions/{$session->session_id}", [
'status' => ChatSessionStatus::OPEN,
])->assertOk()->assertJsonFragment(['status' => ChatSessionStatus::OPEN]);
}
public function test_closed_session_cannot_reopen(): void
{
$user = User::factory()->create();
$headers = $this->authHeader($user);
$service = app(ChatService::class);
$session = $service->createSession('Closed Session');
$session->update(['status' => ChatSessionStatus::CLOSED]);
$this->withHeaders($headers)->patchJson("/api/sessions/{$session->session_id}", [
'status' => ChatSessionStatus::OPEN,
])->assertStatus(403);
}
public function test_empty_patch_rejected(): void
{
$user = User::factory()->create();
$headers = $this->authHeader($user);
$service = app(ChatService::class);
$session = $service->createSession('Patch Empty');
$this->withHeaders($headers)->patchJson("/api/sessions/{$session->session_id}", [])
->assertStatus(422);
}
public function test_archive_is_idempotent_and_blocks_user_prompt(): void
{
$user = User::factory()->create();
$headers = $this->authHeader($user);
$service = app(ChatService::class);
$session = $service->createSession('Archive');
$this->withHeaders($headers)->postJson("/api/sessions/{$session->session_id}/archive")
->assertOk()
->assertJsonFragment(['status' => ChatSessionStatus::CLOSED]);
// repeat archive
$this->withHeaders($headers)->postJson("/api/sessions/{$session->session_id}/archive")
->assertOk()
->assertJsonFragment(['status' => ChatSessionStatus::CLOSED]);
// append should be blocked
$this->withHeaders($headers)->postJson("/api/sessions/{$session->session_id}/messages", [
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'blocked',
])->assertStatus(403);
}
public function test_get_message_respects_session_scope(): void
{
$user = User::factory()->create();
$headers = $this->authHeader($user);
$service = app(ChatService::class);
$s1 = $service->createSession('S1');
$s2 = $service->createSession('S2');
$msg1 = $service->appendMessage([
'session_id' => $s1->session_id,
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'hello',
]);
$this->withHeaders($headers)->getJson("/api/sessions/{$s1->session_id}/messages/{$msg1->message_id}")
->assertOk()
->assertJsonFragment(['message_id' => $msg1->message_id]);
// wrong session should 404
$this->withHeaders($headers)->getJson("/api/sessions/{$s2->session_id}/messages/{$msg1->message_id}")
->assertNotFound();
}
public function test_publish_to_redis_on_append(): void
{
Redis::shouldReceive('publish')->once()->andReturn(1);
$service = app(ChatService::class);
$session = $service->createSession('Redis Pub');
$service->appendMessage([
'session_id' => $session->session_id,
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'hello',
]);
}
public function test_sse_backlog_contains_messages(): void
{
$user = User::factory()->create();
$headers = $this->authHeader($user);
$service = app(ChatService::class);
$session = $service->createSession('SSE Session');
$service->appendMessage([
'session_id' => $session->session_id,
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'hello sse',
]);
$response = $this->withHeaders($headers)->get("/api/sessions/{$session->session_id}/sse?after_seq=0");
$response->assertOk();
$content = $response->baseResponse instanceof \Symfony\Component\HttpFoundation\StreamedResponse
? $response->streamedContent()
: $response->getContent();
$this->assertStringContainsString('id: 1', $content);
$this->assertStringContainsString('event: message', $content);
$this->assertStringContainsString('hello sse', $content);
}
}