Skip to content

Commit 5d0d5d0

Browse files
committed
test(requests): add smoke.ps1 driver + README; replays all 30 .http files end-to-end
1 parent e2a3b65 commit 5d0d5d0

3 files changed

Lines changed: 366 additions & 0 deletions

File tree

‎BuberDinner.sln‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ EndProject
3939
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Requests", "Requests", "{0E4C8F09-4F38-4804-8D8A-3E614856A3DD}"
4040
ProjectSection(SolutionItems) = preProject
4141
Requests\httpenv.json = Requests\httpenv.json
42+
Requests\README.md = Requests\README.md
43+
Requests\smoke.ps1 = Requests\smoke.ps1
4244
EndProjectSection
4345
EndProject
4446
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Authentication", "Authentication", "{65EC07A7-FE7E-455A-A498-E58B7B078FFC}"

‎Requests/README.md‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Requests
2+
3+
VS Code / Visual Studio REST Client `.http` files for the BuberDinner API,
4+
plus a PowerShell driver that replays all of them as an end-to-end smoke test.
5+
6+
## Layout
7+
8+
Each subfolder mirrors a controller area:
9+
10+
| Folder | Contents |
11+
|---|---|
12+
| `Authentication/` | Register, Login, Login-InvalidCredentials |
13+
| `Hosts/` | CreateHost |
14+
| `Menus/` | Create / Get / Update / List + ETag-flow failure modes |
15+
| `Dinners/` | Schedule / Get / List (with cursor pagination edge cases) / Start / End / Cancel + lifecycle failure modes |
16+
| `Reservations/` | Create (idempotent) / Cancel / ListMine + failure modes |
17+
| `MenuReviews/` | Submit / Update / List + FluentValidation failure modes |
18+
19+
`httpenv.json` defines the `{{host}}` variable for the dev profile
20+
(`https://localhost:7059`, matching `Api/src/Properties/launchSettings.json`).
21+
22+
## Running individual files (REST Client)
23+
24+
Authenticate first:
25+
26+
1. Open `Authentication/Register.http`, run the request, copy the `token` from the response.
27+
2. In every other `.http` file, replace `@token = yourtoken` with the value you copied.
28+
3. Each file documents which other `@variable` values (hostId, menuId, dinnerId, etc.)
29+
you need to substitute in, and which prior file produces them.
30+
31+
## Running everything (`smoke.ps1`)
32+
33+
`smoke.ps1` replays every `.http` file against a running API instance, threading
34+
IDs / ETags / Idempotency-Keys between calls. Use it to verify the suite end-to-end
35+
after any controller or contract change.
36+
37+
```pwsh
38+
# Terminal 1: start the API.
39+
dotnet run --project Api/src --launch-profile InMemory
40+
41+
# Terminal 2: run the smoke driver.
42+
pwsh Requests/smoke.ps1
43+
```
44+
45+
Options:
46+
47+
```pwsh
48+
# Run against a non-default host.
49+
pwsh Requests/smoke.ps1 -ApiHost https://localhost:5001
50+
```
51+
52+
The script:
53+
54+
- Generates unique `userId`s per run (suffixed with epoch seconds) so reruns
55+
against the same in-memory instance don't trip on 409 / fingerprint mismatches.
56+
- Asserts the HTTP status code (and a few derived fields like `wasCapped`); does
57+
not validate full response bodies — it's a smoke driver, not a contract test.
58+
- Exits with `0` when every call passes; otherwise exits with the count of failures.

‎Requests/smoke.ps1‎

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
<#
2+
.SYNOPSIS
3+
End-to-end smoke test for every .http file in this folder.
4+
5+
.DESCRIPTION
6+
Replays the request from each .http file against a running BuberDinner.Api
7+
instance, threading IDs / ETags / Idempotency-Keys between calls so dependent
8+
requests (e.g. UpdateMenu after GetMenu, SubmitReview after a reserved-and-
9+
ended dinner) see realistic state.
10+
11+
Each test reports the file name, expected HTTP status, and the actual status.
12+
Process exit code = number of failures.
13+
14+
This is a smoke driver, not an exhaustive contract test:
15+
- It asserts only the HTTP status code (and a handful of derived fields
16+
like `wasCapped`) — not the full response body.
17+
- It uses unique userIds per run (suffixed with epoch seconds) so reruns
18+
against the same in-memory instance don't trip on 409 / fingerprint
19+
mismatches from prior runs.
20+
21+
.PARAMETER ApiHost
22+
Base URL of the running API. Defaults to https://localhost:7059 (matches
23+
the dev launchSettings profile).
24+
25+
.EXAMPLE
26+
# 1. Start the API in another terminal:
27+
dotnet run --project Api/src --launch-profile InMemory
28+
29+
# 2. Run the smoke driver:
30+
pwsh Requests/smoke.ps1
31+
32+
.EXAMPLE
33+
# Run against a non-default host:
34+
pwsh Requests/smoke.ps1 -ApiHost https://localhost:5001
35+
#>
36+
[CmdletBinding()]
37+
param(
38+
[string]$ApiHost = 'https://localhost:7059'
39+
)
40+
41+
$ErrorActionPreference = 'Stop'
42+
$results = New-Object System.Collections.Generic.List[object]
43+
44+
function Send {
45+
param(
46+
[string]$file,
47+
[string]$method,
48+
[string]$path,
49+
[hashtable]$headers,
50+
$body,
51+
[int[]]$expect
52+
)
53+
if ($null -eq $headers) { $headers = @{} }
54+
if ($body -is [hashtable]) { $body = ($body | ConvertTo-Json -Depth 10 -Compress) }
55+
$req = @{
56+
Uri = "$ApiHost$path"
57+
Method = $method
58+
Headers = $headers
59+
SkipCertificateCheck = $true
60+
SkipHttpErrorCheck = $true
61+
UseBasicParsing = $true
62+
TimeoutSec = 30
63+
}
64+
if ($body) { $req.Body = $body; $req.ContentType = 'application/json' }
65+
try {
66+
$r = Invoke-WebRequest @req
67+
$status = [int]$r.StatusCode
68+
$pass = $expect -contains $status
69+
$bodyText = $r.Content
70+
$etag = if ($r.Headers.ContainsKey('ETag')) { ($r.Headers.ETag | Select-Object -First 1).ToString() } else { $null }
71+
} catch {
72+
$status = -1
73+
$pass = $false
74+
$bodyText = $_.Exception.Message
75+
$etag = $null
76+
}
77+
$row = [PSCustomObject]@{
78+
File = $file
79+
Method = $method
80+
Path = $path
81+
Expected = ($expect -join ',')
82+
Actual = $status
83+
Pass = $pass
84+
Etag = $etag
85+
Body = $bodyText
86+
}
87+
$script:results.Add($row)
88+
$tick = if ($pass) { 'PASS' } else { 'FAIL' }
89+
$color = if ($pass) { 'Green' } else { 'Red' }
90+
Write-Host ("{0,-4} {1,-3} {2,-55} expect={3,-10} actual={4}" -f $tick, $method, $file, $row.Expected, $status) -ForegroundColor $color
91+
if (-not $pass -and $bodyText) {
92+
$snippet = ($bodyText -replace '\s+', ' ').Substring(0, [Math]::Min(220, $bodyText.Length))
93+
Write-Host " -> $snippet" -ForegroundColor Yellow
94+
}
95+
return $row
96+
}
97+
98+
function FromJson($text) { try { $text | ConvertFrom-Json } catch { $null } }
99+
100+
Write-Host "Smoke testing $ApiHost" -ForegroundColor Cyan
101+
102+
# ---------- Phase 1: Auth + Hosts ----------
103+
# Unique userIds per run so reruns don't 409.
104+
$stamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()
105+
$user1 = "smoke1_$stamp"
106+
$user2 = "smoke2_$stamp"
107+
108+
$r = Send 'Authentication/Register.http (user1)' 'POST' '/authentication/register' @{} @{
109+
userId = $user1; firstName = 'Smoke'; lastName = 'One'; email = 's1@example.com'; password = 'Smoke1234!'
110+
} @(200, 201)
111+
$token1 = (FromJson $r.Body).token
112+
113+
$r = Send 'Authentication/Register.http (user2)' 'POST' '/authentication/register' @{} @{
114+
userId = $user2; firstName = 'Smoke'; lastName = 'Two'; email = 's2@example.com'; password = 'Smoke1234!'
115+
} @(200, 201)
116+
$token2 = (FromJson $r.Body).token
117+
118+
if (-not $token1 -or -not $token2) {
119+
Write-Host 'No tokens captured - aborting.' -ForegroundColor Red
120+
exit 1
121+
}
122+
123+
Send 'Authentication/Login.http' 'POST' '/authentication/login' @{} @{ userId = $user1; password = 'Smoke1234!' } @(200) | Out-Null
124+
Send 'Authentication/Login-InvalidCredentials.http' 'POST' '/authentication/login' @{} @{ userId = $user1; password = 'WrongPassword!' } @(401) | Out-Null
125+
126+
$auth1 = @{ Authorization = "Bearer $token1" }
127+
$auth2 = @{ Authorization = "Bearer $token2" }
128+
129+
$r = Send 'Hosts/CreateHost.http (host1)' 'POST' '/hosts?api-version=2022-10-01' $auth1 @{ displayName = "Smoke's Kitchen" } @(201)
130+
$hostId1 = (FromJson $r.Body).id
131+
$r = Send 'Hosts/CreateHost.http (host2 for cross-owner tests)' 'POST' '/hosts?api-version=2022-10-01' $auth2 @{ displayName = "Other Kitchen" } @(201)
132+
$hostId2 = (FromJson $r.Body).id
133+
134+
# ---------- Phase 2: Menus ----------
135+
$createMenuBody = @{
136+
name = 'Muffins R Us'
137+
description = 'Menu for Muffins R Us'
138+
sections = @(
139+
@{ name = 'Muffins'; description = 'The Muffins section'; items = @(
140+
@{ name = 'Blueberry'; description = 'A Blueberry Muffin' },
141+
@{ name = 'Chocolate'; description = 'A Chocolate Muffin' }) },
142+
@{ name = 'Not Muffins'; description = 'Anything that is not a muffin'; items = @(
143+
@{ name = 'Cookie'; description = 'Probably oatmeal raisin' }) }
144+
)
145+
}
146+
$r = Send 'Menus/CreateMenu.http' 'POST' "/hosts/$hostId1/menus/create?api-version=2022-10-01" $auth1 $createMenuBody @(201)
147+
$menuId1 = (FromJson $r.Body).id
148+
149+
$badNested = @{
150+
name = 'Valid Menu'; description = 'Valid Description'
151+
sections = @(@{ name = ''; description = 'Section description'; items = @(@{ name = 'Item name'; description = '' }) })
152+
}
153+
Send 'Menus/CreateMenu-InvalidNested.http' 'POST' "/hosts/$hostId1/menus/create?api-version=2022-10-01" $auth1 $badNested @(422) | Out-Null
154+
155+
$r = Send 'Menus/GetMenu.http' 'GET' "/hosts/$hostId1/menus/$menuId1`?api-version=2022-10-01" $auth1 $null @(200)
156+
$etagMenu1 = $r.Etag
157+
158+
Send 'Menus/GetMenu-NotModified.http' 'GET' "/hosts/$hostId1/menus/$menuId1`?api-version=2022-10-01" `
159+
($auth1 + @{ 'If-None-Match' = $etagMenu1 }) $null @(304) | Out-Null
160+
161+
$r = Send 'Menus/UpdateMenu.http' 'PUT' "/hosts/$hostId1/menus/$menuId1`?api-version=2022-10-01" `
162+
($auth1 + @{ 'If-Match' = $etagMenu1 }) @{ name = 'Brunch v2'; description = 'Updated brunch menu' } @(200)
163+
$etagMenu1 = $r.Etag
164+
165+
# UpdateMenu-IfMatchMismatch.http: 3 scenarios in one file
166+
Send 'Menus/UpdateMenu-IfMatchMismatch.http (no If-Match -> 428)' 'PUT' "/hosts/$hostId1/menus/$menuId1`?api-version=2022-10-01" $auth1 `
167+
@{ name = 'X'; description = 'Y' } @(428) | Out-Null
168+
Send 'Menus/UpdateMenu-IfMatchMismatch.http (stale If-Match -> 412)' 'PUT' "/hosts/$hostId1/menus/$menuId1`?api-version=2022-10-01" `
169+
($auth1 + @{ 'If-Match' = '"deadbeefdeadbeefdeadbeefdeadbeef"' }) @{ name = 'X'; description = 'Y' } @(412) | Out-Null
170+
Send 'Menus/UpdateMenu-IfMatchMismatch.http (cross-owner -> 403)' 'PUT' "/hosts/$hostId1/menus/$menuId1`?api-version=2022-10-01" `
171+
($auth2 + @{ 'If-Match' = $etagMenu1 }) @{ name = 'Pwned'; description = 'By a different user' } @(403, 404) | Out-Null
172+
173+
Send 'Menus/ListMenus.http' 'GET' "/hosts/$hostId1/menus?api-version=2022-10-01&limit=5" $auth1 $null @(200) | Out-Null
174+
175+
# ---------- Phase 3: Dinners ----------
176+
$scheduleBody = @{
177+
name = 'Brunch with friends'; description = 'Casual Sunday brunch'
178+
menuId = $menuId1; startDateTime = '2026-07-01T18:00:00Z'; endDateTime = '2026-07-01T21:00:00Z'
179+
}
180+
$r = Send 'Dinners/ScheduleDinner.http' 'POST' "/hosts/$hostId1/dinners?api-version=2022-10-01" $auth1 $scheduleBody @(201)
181+
$dinnerId1 = (FromJson $r.Body).id
182+
183+
Send 'Dinners/GetDinner.http' 'GET' "/hosts/$hostId1/dinners/$dinnerId1`?api-version=2022-10-01" $auth1 $null @(200) | Out-Null
184+
Send 'Dinners/ListDinners.http' 'GET' "/hosts/$hostId1/dinners?api-version=2022-10-01" $auth1 $null @(200) | Out-Null
185+
186+
$r = Send 'Dinners/ListDinners-OversizedLimit.http' 'GET' "/hosts/$hostId1/dinners?api-version=2022-10-01&limit=500" $auth1 $null @(200)
187+
$wasCapped = (FromJson $r.Body).wasCapped
188+
if (-not $wasCapped) {
189+
Write-Host ' warning: expected wasCapped=true on oversized limit' -ForegroundColor Yellow
190+
}
191+
192+
Send 'Dinners/ListDinners-MalformedCursor.http' 'GET' "/hosts/$hostId1/dinners?api-version=2022-10-01&cursor=NOT-A-VALID-CURSOR-!!!" $auth1 $null @(422) | Out-Null
193+
194+
# Create a few more dinners so we can capture a real `next.cursor` for the With-Cursor test.
195+
foreach ($i in 2..6) {
196+
$body = @{
197+
name = "Dinner $i"; description = "d$i"; menuId = $menuId1
198+
startDateTime = '2026-07-01T18:00:00Z'; endDateTime = '2026-07-01T21:00:00Z'
199+
}
200+
Send "Dinners/Schedule extra #$i" 'POST' "/hosts/$hostId1/dinners?api-version=2022-10-01" $auth1 $body @(201) | Out-Null
201+
}
202+
$r = Send 'Dinners ListPage1 (capture cursor)' 'GET' "/hosts/$hostId1/dinners?api-version=2022-10-01&limit=3" $auth1 $null @(200)
203+
$cursor = (FromJson $r.Body).next.cursor
204+
if ($cursor) {
205+
Send 'Dinners/ListDinners-WithCursor.http' 'GET' "/hosts/$hostId1/dinners?api-version=2022-10-01&limit=3&cursor=$cursor" $auth1 $null @(200) | Out-Null
206+
} else {
207+
Write-Host 'SKIP Dinners/ListDinners-WithCursor.http (no next cursor)' -ForegroundColor DarkGray
208+
}
209+
210+
Send 'Dinners/StartDinner.http' 'POST' "/hosts/$hostId1/dinners/$dinnerId1/start?api-version=2022-10-01" $auth1 $null @(200) | Out-Null
211+
Send 'Dinners/Lifecycle-InvalidTransition.http (cancel after start -> 422)' 'POST' "/hosts/$hostId1/dinners/$dinnerId1/cancel?api-version=2022-10-01" $auth1 @{ reason = 'Too late' } @(422) | Out-Null
212+
Send 'Dinners/Lifecycle-InvalidTransition.http (cross-host start -> 403)' 'POST' "/hosts/$hostId1/dinners/$dinnerId1/start?api-version=2022-10-01" $auth2 $null @(403, 404) | Out-Null
213+
Send 'Dinners/EndDinner.http' 'POST' "/hosts/$hostId1/dinners/$dinnerId1/end?api-version=2022-10-01" $auth1 $null @(200) | Out-Null
214+
215+
# CancelDinner.http requires a fresh Upcoming dinner.
216+
$body = @{
217+
name = 'Sunday brunch v2'; description = 'Will be cancelled'; menuId = $menuId1
218+
startDateTime = '2026-07-01T18:00:00Z'; endDateTime = '2026-07-01T21:00:00Z'
219+
}
220+
$r = Send 'Dinners Schedule for cancel' 'POST' "/hosts/$hostId1/dinners?api-version=2022-10-01" $auth1 $body @(201)
221+
$dinnerToCancel = (FromJson $r.Body).id
222+
Send 'Dinners/CancelDinner.http' 'POST' "/hosts/$hostId1/dinners/$dinnerToCancel/cancel?api-version=2022-10-01" $auth1 @{ reason = 'Host illness' } @(200) | Out-Null
223+
224+
# ---------- Phase 4: Reservations ----------
225+
$body = @{
226+
name = 'Brunch for reservations'; description = 'Reservation testing'; menuId = $menuId1
227+
startDateTime = '2026-07-01T18:00:00Z'; endDateTime = '2026-07-01T21:00:00Z'
228+
}
229+
$r = Send 'Dinners Schedule for reservations' 'POST' "/hosts/$hostId1/dinners?api-version=2022-10-01" $auth1 $body @(201)
230+
$dinnerRes = (FromJson $r.Body).id
231+
232+
$key = [Guid]::NewGuid().ToString()
233+
$r = Send 'Reservations/CreateReservation.http' 'POST' '/reservations?api-version=2022-10-01' `
234+
($auth2 + @{ 'Idempotency-Key' = $key }) @{ dinnerId = $dinnerRes; guestCount = 2 } @(201)
235+
$resId1 = (FromJson $r.Body).id
236+
237+
Send 'Reservations/CreateReservation.http (replay)' 'POST' '/reservations?api-version=2022-10-01' `
238+
($auth2 + @{ 'Idempotency-Key' = $key }) @{ dinnerId = $dinnerRes; guestCount = 2 } @(201) | Out-Null
239+
240+
Send 'Reservations/CancelReservation.http' 'POST' "/reservations/$resId1/cancel?api-version=2022-10-01" $auth2 @{ reason = 'Schedule conflict' } @(200) | Out-Null
241+
242+
$key2 = [Guid]::NewGuid().ToString()
243+
Send 'Reservations CreateReservation (for list)' 'POST' '/reservations?api-version=2022-10-01' `
244+
($auth2 + @{ 'Idempotency-Key' = $key2 }) @{ dinnerId = $dinnerRes; guestCount = 1 } @(201) | Out-Null
245+
246+
Send 'Reservations/ListMyReservations.http' 'GET' '/reservations/mine?api-version=2022-10-01&limit=10' $auth2 $null @(200) | Out-Null
247+
248+
Send 'Reservations/CreateReservation-FailureModes.http (no Idempotency-Key -> 400)' 'POST' '/reservations?api-version=2022-10-01' $auth2 `
249+
@{ dinnerId = $dinnerRes; guestCount = 1 } @(400) | Out-Null
250+
Send 'Reservations/CreateReservation-FailureModes.http (key+different body -> 422)' 'POST' '/reservations?api-version=2022-10-01' `
251+
($auth2 + @{ 'Idempotency-Key' = $key2 }) @{ dinnerId = $dinnerRes; guestCount = 99 } @(422, 409) | Out-Null
252+
$phantom = '00000000-1111-2222-3333-444444444444'
253+
$keyPhantom = [Guid]::NewGuid().ToString()
254+
Send 'Reservations/CreateReservation-FailureModes.http (phantom dinner -> 404)' 'POST' '/reservations?api-version=2022-10-01' `
255+
($auth2 + @{ 'Idempotency-Key' = $keyPhantom }) @{ dinnerId = $phantom; guestCount = 1 } @(404) | Out-Null
256+
257+
Send 'Dinners/ListReservationsForDinner.http' 'GET' "/hosts/$hostId1/dinners/$dinnerRes/reservations?api-version=2022-10-01&limit=10" $auth1 $null @(200) | Out-Null
258+
259+
# ---------- Phase 5: MenuReviews ----------
260+
# Reviews require: caller reserved the dinner AND dinner is Ended.
261+
$body = @{
262+
name = 'Brunch for reviews'; description = 'Review testing'; menuId = $menuId1
263+
startDateTime = '2026-07-01T18:00:00Z'; endDateTime = '2026-07-01T21:00:00Z'
264+
}
265+
$r = Send 'Dinners Schedule for review' 'POST' "/hosts/$hostId1/dinners?api-version=2022-10-01" $auth1 $body @(201)
266+
$dinnerRev = (FromJson $r.Body).id
267+
$keyRev = [Guid]::NewGuid().ToString()
268+
Send 'Reservations CreateReservation (for review)' 'POST' '/reservations?api-version=2022-10-01' `
269+
($auth2 + @{ 'Idempotency-Key' = $keyRev }) @{ dinnerId = $dinnerRev; guestCount = 1 } @(201) | Out-Null
270+
Send 'Dinners Start (for review)' 'POST' "/hosts/$hostId1/dinners/$dinnerRev/start?api-version=2022-10-01" $auth1 $null @(200) | Out-Null
271+
Send 'Dinners End (for review)' 'POST' "/hosts/$hostId1/dinners/$dinnerRev/end?api-version=2022-10-01" $auth1 $null @(200) | Out-Null
272+
273+
$r = Send 'MenuReviews/SubmitReview.http' 'POST' '/menu-reviews?api-version=2022-10-01' $auth2 `
274+
@{ menuId = $menuId1; dinnerId = $dinnerRev; rating = 4; comment = 'Loved the brunch.' } @(201)
275+
$reviewId1 = (FromJson $r.Body).id
276+
277+
Send 'MenuReviews/UpdateReview.http' 'PUT' "/menu-reviews/$reviewId1`?api-version=2022-10-01" $auth2 `
278+
@{ rating = 5; comment = 'Even better on second visit' } @(200) | Out-Null
279+
280+
Send 'MenuReviews/ListReviewsForMenu.http' 'GET' "/menu-reviews/for-menu/$menuId1`?api-version=2022-10-01&limit=10" $auth2 $null @(200) | Out-Null
281+
282+
Send 'MenuReviews/SubmitReview-ValidationFailure.http (rating=99 -> 422)' 'POST' '/menu-reviews?api-version=2022-10-01' $auth2 `
283+
@{ menuId = $menuId1; dinnerId = $dinnerRev; rating = 99; comment = 'anything' } @(422) | Out-Null
284+
Send 'MenuReviews/SubmitReview-ValidationFailure.http (empty comment -> 422)' 'POST' '/menu-reviews?api-version=2022-10-01' $auth2 `
285+
@{ menuId = $menuId1; dinnerId = $dinnerRev; rating = 3; comment = '' } @(422) | Out-Null
286+
Send 'MenuReviews/SubmitReview-ValidationFailure.http (both wrong -> 422)' 'POST' '/menu-reviews?api-version=2022-10-01' $auth2 `
287+
@{ menuId = $menuId1; dinnerId = $dinnerRev; rating = 0; comment = '' } @(422) | Out-Null
288+
289+
# ---------- Summary ----------
290+
$pass = ($results | Where-Object Pass).Count
291+
$fail = ($results | Where-Object { -not $_.Pass }).Count
292+
Write-Host ''
293+
Write-Host '================== SUMMARY ==================' -ForegroundColor Cyan
294+
Write-Host ("Total: {0} Pass: {1} Fail: {2}" -f $results.Count, $pass, $fail)
295+
if ($fail -gt 0) {
296+
Write-Host ''
297+
Write-Host 'FAILURES:' -ForegroundColor Red
298+
foreach ($f in ($results | Where-Object { -not $_.Pass })) {
299+
Write-Host (" [{0}] {1} {2} expect={3} actual={4}" -f $f.File, $f.Method, $f.Path, $f.Expected, $f.Actual)
300+
if ($f.Body) {
301+
$snippet = ($f.Body -replace '\s+', ' ').Substring(0, [Math]::Min(220, $f.Body.Length))
302+
Write-Host " -> $snippet" -ForegroundColor Yellow
303+
}
304+
}
305+
}
306+
exit $fail

0 commit comments

Comments
 (0)