- POST /api/messages - Send a message (creates conversation if needed)
- PUT /api/messages/{id}/read - Mark all messages in conversation as read
- Docker Desktop installed and running
docker compose up -d# Alice (admin, user 1)
$aliceLogin = @{
email = "admin@acme.test"
password = "Test123!"
tenant_slug = "acme"
} | ConvertTo-Json
$aliceResponse = Invoke-RestMethod -Uri "http://localhost:5080/api/auth/login" -Method POST -Body $aliceLogin -ContentType "application/json"
$aliceToken = $aliceResponse.access_token
$aliceHeaders = @{ Authorization = "Bearer $aliceToken" }
# Charlie (member, user 3)
$charlieLogin = @{
email = "member@acme.test"
password = "Test123!"
tenant_slug = "acme"
} | ConvertTo-Json
$charlieResponse = Invoke-RestMethod -Uri "http://localhost:5080/api/auth/login" -Method POST -Body $charlieLogin -ContentType "application/json"
$charlieToken = $charlieResponse.access_token
$charlieHeaders = @{ Authorization = "Bearer $charlieToken" }
# Bob (Globex admin, user 2)
$bobLogin = @{
email = "admin@globex.test"
password = "Test123!"
tenant_slug = "globex"
} | ConvertTo-Json
$bobResponse = Invoke-RestMethod -Uri "http://localhost:5080/api/auth/login" -Method POST -Body $bobLogin -ContentType "application/json"
$bobToken = $bobResponse.access_token
$bobHeaders = @{ Authorization = "Bearer $bobToken" }Expected: 201 Created, message added to existing conversation
# Alice sends a message to Charlie (they already have conversation 1)
$messageBody = @{
recipient_id = 3
content = "Thanks for the lawn mowing offer! I might take you up on that."
} | ConvertTo-Json
$response = Invoke-RestMethod -Uri "http://localhost:5080/api/messages" -Method POST -Body $messageBody -ContentType "application/json" -Headers $aliceHeaders
$response | ConvertTo-Json -Depth 5
# Expected: conversation_id = 1 (existing conversation)Expected: 201 Created with new conversation
# Bob sends a message to himself... wait, Bob is in Globex, alone
# Let's check if Bob can message anyone - there's no one else in Globex
# So let's just verify the cross-tenant test (Test 7) worksExpected: 200 OK with count of marked messages
# First check Alice's unread count
$response = Invoke-RestMethod -Uri "http://localhost:5080/api/messages/unread-count" -Method GET -Headers $aliceHeaders
Write-Host "Alice unread before: $($response.unread_count)"
# Mark conversation 1 as read (Alice reading Charlie's messages)
$response = Invoke-RestMethod -Uri "http://localhost:5080/api/messages/1/read" -Method PUT -Headers $aliceHeaders
$response | ConvertTo-Json
# Expected: marked_read >= 1 (at least the seed unread message)
# Check Alice's unread count after
$response = Invoke-RestMethod -Uri "http://localhost:5080/api/messages/unread-count" -Method GET -Headers $aliceHeaders
Write-Host "Alice unread after: $($response.unread_count)"
# Expected: 0Expected: 400 Bad Request
try {
$body = @{ recipient_id = 3; content = "" } | ConvertTo-Json
Invoke-RestMethod -Uri "http://localhost:5080/api/messages" -Method POST -Body $body -ContentType "application/json" -Headers $aliceHeaders
Write-Host "FAIL: Should have rejected empty content"
} catch {
Write-Host "Status: $($_.Exception.Response.StatusCode.value__)"
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
$reader.ReadToEnd()
}
# Expected: {"error":"Message content is required"}Expected: 400 Bad Request
try {
$body = @{ recipient_id = 1; content = "Hello myself" } | ConvertTo-Json
Invoke-RestMethod -Uri "http://localhost:5080/api/messages" -Method POST -Body $body -ContentType "application/json" -Headers $aliceHeaders
Write-Host "FAIL: Should have rejected self-message"
} catch {
Write-Host "Status: $($_.Exception.Response.StatusCode.value__)"
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
$reader.ReadToEnd()
}
# Expected: {"error":"Cannot send message to yourself"}Expected: 400 Bad Request
try {
$body = @{ recipient_id = 99999; content = "Hello" } | ConvertTo-Json
Invoke-RestMethod -Uri "http://localhost:5080/api/messages" -Method POST -Body $body -ContentType "application/json" -Headers $aliceHeaders
Write-Host "FAIL: Should have rejected invalid recipient"
} catch {
Write-Host "Status: $($_.Exception.Response.StatusCode.value__)"
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
$reader.ReadToEnd()
}
# Expected: {"error":"Recipient not found"}Expected: 400 Bad Request (recipient not found due to tenant filter)
try {
# Alice (ACME) tries to message Bob (Globex)
$body = @{ recipient_id = 2; content = "Hello Bob" } | ConvertTo-Json
Invoke-RestMethod -Uri "http://localhost:5080/api/messages" -Method POST -Body $body -ContentType "application/json" -Headers $aliceHeaders
Write-Host "FAIL: Should have rejected cross-tenant message"
} catch {
Write-Host "Status: $($_.Exception.Response.StatusCode.value__)"
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
$reader.ReadToEnd()
}
# Expected: {"error":"Recipient not found"} - Bob is in different tenantExpected: 404 Not Found
try {
Invoke-RestMethod -Uri "http://localhost:5080/api/messages/999/read" -Method PUT -Headers $aliceHeaders
Write-Host "FAIL: Should have returned 404"
} catch {
Write-Host "Status: $($_.Exception.Response.StatusCode.value__)"
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
$reader.ReadToEnd()
}
# Expected: 404Expected: 404 Not Found
try {
# Bob (Globex) tries to mark ACME conversation as read
Invoke-RestMethod -Uri "http://localhost:5080/api/messages/1/read" -Method PUT -Headers $bobHeaders
Write-Host "FAIL: Should have returned 404"
} catch {
Write-Host "Status: $($_.Exception.Response.StatusCode.value__)"
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
$reader.ReadToEnd()
}
# Expected: 404# Get conversation messages after sending
$response = Invoke-RestMethod -Uri "http://localhost:5080/api/messages/1" -Method GET -Headers $aliceHeaders
Write-Host "Total messages in conversation: $($response.pagination.total)"
Write-Host "Latest messages:"
$response.messages | Select-Object -First 3 | ForEach-Object {
Write-Host " [$($_.sender.first_name)]: $($_.content.Substring(0, [Math]::Min(50, $_.content.Length)))..."
}try {
$body = @{ recipient_id = 3; content = "Hello" } | ConvertTo-Json
Invoke-RestMethod -Uri "http://localhost:5080/api/messages" -Method POST -Body $body -ContentType "application/json"
Write-Host "FAIL: Should have returned 401"
} catch {
Write-Host "Status: $($_.Exception.Response.StatusCode.value__)"
}
# Expected: 401| Test | Description | Expected Result |
|---|---|---|
| 1 | Send to existing conversation | 201 Created, conversation_id=1 |
| 3 | Mark conversation read | 200 OK, marked_read >= 1 |
| 4 | Empty content | 400 Bad Request |
| 5 | Message yourself | 400 Bad Request |
| 6 | Invalid recipient | 400 Bad Request |
| 7 | Cross-tenant message | 400 Bad Request |
| 8 | Mark non-existent read | 404 Not Found |
| 9 | Mark other tenant's read | 404 Not Found |
| 10 | Verify in history | New message visible |
| 11 | Unauthenticated | 401 Unauthorized |
Request:
{
"recipient_id": 3,
"content": "Hello! How are you?"
}Response (201 Created):
{
"id": 6,
"conversation_id": 1,
"content": "Hello! How are you?",
"sender": {
"id": 1,
"first_name": "Alice",
"last_name": "Admin"
},
"recipient": {
"id": 3,
"first_name": "Charlie",
"last_name": "Contributor"
},
"is_read": false,
"created_at": "2026-02-02T11:00:00Z"
}Response (200 OK):
{
"conversation_id": 1,
"marked_read": 2
}- Sending to a new recipient automatically creates a conversation
- Conversation participants are normalized (smaller ID first) for uniqueness
- Mark as read only affects messages from the OTHER participant
- Messages are limited to 5000 characters
- Cross-tenant messaging is blocked by tenant filter on Users