Compare commits
2 commits
56846e2781
...
abfc68a809
| Author | SHA1 | Date | |
|---|---|---|---|
| abfc68a809 | |||
| 03cd9843d3 |
6 changed files with 374 additions and 35 deletions
65
src/PostIt.Tests/BlogApiTestFakes.cs
Normal file
65
src/PostIt.Tests/BlogApiTestFakes.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using PostIt.Models;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>Per-call ledger shared between the test and the
|
||||
/// recording fake, so the assertion can inspect what the VM
|
||||
/// actually sent on the wire without coupling to the fake's
|
||||
/// internals.</summary>
|
||||
internal sealed class CallRecorder
|
||||
{
|
||||
public (HttpMethod method, string path, object? body) FirstCall =>
|
||||
Calls[0];
|
||||
public List<(HttpMethod method, string path, object? body)> Calls { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Test fake that records every CallAsync invocation
|
||||
/// and answers them with a canned sequence: the first call gets
|
||||
/// a server-issued BlogPost (Id=42), the second call gets a
|
||||
/// single-element list containing that post. Used by the ViewModel
|
||||
/// tests and the headless UI test to capture exactly what the
|
||||
/// Save button posts to the server.</summary>
|
||||
internal sealed class RecordingYavscApiClient : YavscApiClient
|
||||
{
|
||||
private readonly CallRecorder _recorder;
|
||||
public RecordingYavscApiClient(CallRecorder recorder)
|
||||
: base(
|
||||
new Settings
|
||||
{
|
||||
Authentication = new AuthenticationSettings
|
||||
{
|
||||
Authority = "https://stub.invalid",
|
||||
ClientId = "stub",
|
||||
Scopes = new[] { "openid" },
|
||||
},
|
||||
},
|
||||
new TokenStore(System.IO.Path.GetTempFileName()))
|
||||
{
|
||||
_recorder = recorder;
|
||||
}
|
||||
|
||||
public override Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
_recorder.Calls.Add((method, path, body));
|
||||
// BlogPost? boxes to BlogPost at runtime, so we test the
|
||||
// non-nullable type — typeof(BlogPost?) is a C# error
|
||||
// (CS8639: "typeof cannot be used on a nullable reference
|
||||
// type").
|
||||
if (typeof(T) == typeof(BlogPost))
|
||||
return Task.FromResult((T)(object)new BlogPost
|
||||
{
|
||||
Id = 42,
|
||||
Title = "Mon premier billet",
|
||||
AuthorId = "tester",
|
||||
Article = "Contenu du billet de test.",
|
||||
});
|
||||
if (typeof(T) == typeof(List<BlogPost>))
|
||||
return Task.FromResult((T)(object)new List<BlogPost>
|
||||
{
|
||||
new() { Id = 42, Title = "Mon premier billet" }
|
||||
});
|
||||
return Task.FromResult(default(T)!);
|
||||
}
|
||||
}
|
||||
89
src/PostIt.Tests/MainPageSaveTests.cs
Normal file
89
src/PostIt.Tests/MainPageSaveTests.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.VisualTree;
|
||||
using PostIt.Models;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Headless UI tests for the "Save" flow in <see cref="MainPage"/>.
|
||||
/// The pattern is the one <c>SessionStatusBannerTests</c>
|
||||
/// established: <c>[AvaloniaFact]</c>, a <see cref="Window"/>
|
||||
/// hosting the page (via a <see cref="Frame"/> because
|
||||
/// <c>MainPage</c> is a <c>ContentPage</c>), then drive the
|
||||
/// controls through their public surface and assert on what
|
||||
/// <see cref="RecordingYavscApiClient"/> saw go on the wire.
|
||||
///
|
||||
/// <para>The bug we are pinning: the title <c>TextBox</c> is
|
||||
/// currently <c>{Binding SelectedPost.Title, Mode=TwoWay}</c>.
|
||||
/// When <c>SelectedPost is null</c> (i.e. the user has not yet
|
||||
/// clicked an item in the posts list — which is the only state
|
||||
/// in which a brand-new post can be created), the binding has
|
||||
/// no target and the user's keystrokes are silently dropped.
|
||||
/// Clicking "Save" then routes to the VM branch
|
||||
/// <c>if (SelectedPost is null) { new BlogPost { Title = string.Empty, ... } }</c>
|
||||
/// which the controller rejects with 400 "The Title field is
|
||||
/// required." This test fails on that branch today and will
|
||||
/// pass once the VM owns a dedicated <c>Title</c>/<c>Article</c>
|
||||
/// buffer that the XAML binds to and the Save command consumes.</para>
|
||||
/// </summary>
|
||||
public class MainPageSaveTests
|
||||
{
|
||||
[AvaloniaFact]
|
||||
public async Task Typing_a_title_then_clicking_Save_sends_that_title_in_the_post_body()
|
||||
{
|
||||
// Arrange: VM with a recording API client, mounted in a
|
||||
// headless window via a Frame (MainPage is a ContentPage,
|
||||
// not a Control, so it needs a navigation host).
|
||||
var recorder = new CallRecorder();
|
||||
var api = new RecordingYavscApiClient(recorder);
|
||||
var blog = new BlogApiClient(api);
|
||||
var viewModel = new MainPageViewModel(blog);
|
||||
|
||||
var page = new MainPage { DataContext = viewModel };
|
||||
// MainPage is a ContentPage (a Page, not a Control), so it
|
||||
// must be hosted in a navigation surface. The production
|
||||
// MainWindow.axaml uses NavigationPage, and the API is the
|
||||
// same one App.axaml.cs drives at boot (PushAsync, fire-
|
||||
// and-forget in prod because the page is the top of the
|
||||
// stack immediately).
|
||||
var nav = new NavigationPage();
|
||||
_ = nav.PushAsync(page);
|
||||
var window = new Window { Content = nav };
|
||||
window.Show();
|
||||
|
||||
// Act: type a title into the editor's TextBox without
|
||||
// first selecting a post in the list — the only state in
|
||||
// which a new post can be created. Then click Save.
|
||||
var titleBox = window.GetVisualDescendants()
|
||||
.OfType<TextBox>()
|
||||
.First(t => t.PlaceholderText == "Title");
|
||||
const string typed = "Mon premier billet";
|
||||
titleBox.Text = typed;
|
||||
|
||||
var saveButton = window.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.Single(b => b.Content as string == "Save");
|
||||
saveButton.Command!.Execute(null);
|
||||
|
||||
// The Save command is async (RelayCommand over Task) but
|
||||
// ExecuteAsync would await; the sync Execute enqueues the
|
||||
// task on the dispatcher. Give the dispatcher a chance to
|
||||
// run so the awaited CallAsync has actually fired before
|
||||
// we inspect the recorder.
|
||||
await Task.Delay(200);
|
||||
|
||||
// Assert: the first POST to "blog" carried a BlogPost
|
||||
// whose Title is exactly what the user typed. The bug
|
||||
// fails this assertion with Title == string.Empty.
|
||||
Assert.NotEmpty(recorder.Calls);
|
||||
var (method, path, body) = recorder.FirstCall;
|
||||
Assert.Equal(HttpMethod.Post, method);
|
||||
Assert.Equal("blog", path);
|
||||
var sent = Assert.IsType<BlogPost>(body);
|
||||
Assert.Equal(typed, sent.Title);
|
||||
}
|
||||
}
|
||||
|
|
@ -191,7 +191,7 @@ public class YavscApiClient : IAsyncDisposable
|
|||
CancellationToken ct = default)
|
||||
{
|
||||
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
|
||||
var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
var dto = await JsonSerializer.DeserializeAsync<T>(stream,
|
||||
|
|
@ -217,7 +217,7 @@ public class YavscApiClient : IAsyncDisposable
|
|||
CancellationToken ct = default)
|
||||
{
|
||||
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -259,6 +259,48 @@ public class YavscApiClient : IAsyncDisposable
|
|||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the bare <c>response.EnsureSuccessStatusCode()</c>
|
||||
/// call site with one that surfaces the response body in the
|
||||
/// thrown exception. The default behaviour truncates the
|
||||
/// diagnostic to "Response status code does not indicate
|
||||
/// success: 400 (Bad Request)." — useless when the server is
|
||||
/// an ASP.NET Core action returning a <c>ProblemDetails</c>
|
||||
/// that names the field that failed ModelState validation.
|
||||
/// The VM's <c>catch (Exception ex)</c> in
|
||||
/// <c>MainPageViewModel.ExecuteAsync</c> shows
|
||||
/// <c>ex.Message</c> on the status bar, so embedding the body
|
||||
/// here is enough to make the next "click Save" self-explanatory
|
||||
/// (e.g. <i>"Error: 400 — The Title field is required."</i>).
|
||||
/// </summary>
|
||||
private static async Task EnsureSuccessOrThrowAsync(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
if (response.IsSuccessStatusCode) return;
|
||||
|
||||
// Read the body before throwing; once the response is
|
||||
// disposed, the stream is gone. We bound the read to a few
|
||||
// KB so a hostile server can't make us buffer megabytes
|
||||
// just to format an error message.
|
||||
string body = string.Empty;
|
||||
try
|
||||
{
|
||||
var raw = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
body = raw.Length > 1024 ? raw[..1024] + "…" : raw;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Body unreadable: fall back to the default message.
|
||||
}
|
||||
|
||||
var msg = body.Length > 0
|
||||
? $"{(int)response.StatusCode} {response.ReasonPhrase}: {body}"
|
||||
: $"{(int)response.StatusCode} {response.ReasonPhrase}";
|
||||
throw new HttpRequestException(msg, inner: null, statusCode: response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lock the refresh path so concurrent callers don't each rotate
|
||||
/// the refresh token (which Auth0 invalidates on first use).
|
||||
|
|
|
|||
|
|
@ -11,8 +11,28 @@ namespace PostIt.ViewModels;
|
|||
|
||||
public partial class MainPageViewModel : ViewModelBase
|
||||
{
|
||||
/// <summary>Window/tab title. Cosmetic — bound by
|
||||
/// <c>MainPage.axaml</c> if at all. Not the post title.</summary>
|
||||
[ObservableProperty]
|
||||
public partial string Title { get; set; }
|
||||
public partial string WindowTitle { get; set; }
|
||||
|
||||
/// <summary>Editor buffer for the post title. Bound TwoWay to
|
||||
/// the title <c>TextBox</c> in <c>MainPage.axaml</c>. The Save
|
||||
/// command reads from this buffer (not from
|
||||
/// <see cref="SelectedPost"/>) so that typing into a freshly
|
||||
/// mounted editor (no post selected yet) is captured. With the
|
||||
/// previous "{Binding SelectedPost.Title}" binding, the user's
|
||||
/// keystrokes were silently dropped whenever
|
||||
/// <c>SelectedPost was null</c>, which made the editor a trap
|
||||
/// and caused Save to POST a <c>BlogPost</c> with an empty
|
||||
/// title — hence the 400 "The Title field is required".</summary>
|
||||
[ObservableProperty]
|
||||
public partial string DraftTitle { get; set; }
|
||||
|
||||
/// <summary>Editor buffer for the post body. Same pattern as
|
||||
/// <see cref="DraftTitle"/>.</summary>
|
||||
[ObservableProperty]
|
||||
public partial string DraftArticle { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ViewModelBase? CurrentViewModel { get; set; }
|
||||
|
|
@ -78,7 +98,9 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
// (thread-safe dispatcher marshalling) so the duplicate
|
||||
// instance is now merely wasteful, not dangerous.
|
||||
Settings = settings ?? new Settings();
|
||||
Title = "PostIt";
|
||||
WindowTitle = "PostIt";
|
||||
DraftTitle = string.Empty;
|
||||
DraftArticle = string.Empty;
|
||||
CurrentViewModel = this;
|
||||
}
|
||||
|
||||
|
|
@ -97,10 +119,28 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
|
||||
partial void OnSearchTextChanged(string value) => ApplyFilter();
|
||||
|
||||
partial void OnSelectedPostChanged(BlogPost? value) => UpdateCommandStates();
|
||||
partial void OnSelectedPostChanged(BlogPost? value)
|
||||
{
|
||||
// Mirror the selection into the editor buffer so the
|
||||
// XAML-bound TextBox/TextEditor show the right content
|
||||
// when the user clicks a post in the list. When the
|
||||
// selection is cleared (e.g. after a successful create
|
||||
// rebinds to the server-issued record, or Delete
|
||||
// nulls it out), the buffer is reset so the editor
|
||||
// doesn't show stale content.
|
||||
DraftTitle = value?.Title ?? string.Empty;
|
||||
DraftArticle = value?.Article ?? string.Empty;
|
||||
UpdateCommandStates();
|
||||
}
|
||||
|
||||
partial void OnIsBusyChanged(bool value) => UpdateCommandStates();
|
||||
|
||||
// Save's CanExecute depends on the buffer: the button must
|
||||
// enable as soon as the user has typed a non-whitespace
|
||||
// title, regardless of whether a post is selected.
|
||||
partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
|
||||
partial void OnDraftArticleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
|
||||
|
||||
[RelayCommand]
|
||||
internal async Task LoadPosts()
|
||||
{
|
||||
|
|
@ -123,38 +163,39 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
[RelayCommand]
|
||||
internal async Task Save()
|
||||
{
|
||||
// No selection means "create a new post from the editor".
|
||||
// The server is the source of truth, so we POST without an id
|
||||
// and let BlogApiController assign one. The local view-model
|
||||
// is then rebound to the server-issued record.
|
||||
if (SelectedPost is null)
|
||||
// The button is already disabled when the title is empty
|
||||
// (see CanSave), but the test path (and any programmatic
|
||||
// ICommand.Execute) bypasses CanExecute, so we still
|
||||
// guard here. Better to no-op with a status message
|
||||
// than to send a request the server will reject.
|
||||
if (string.IsNullOrWhiteSpace(DraftTitle))
|
||||
{
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Title = string.Empty,
|
||||
Article = string.Empty,
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
await ExecuteAsync(async () =>
|
||||
{
|
||||
var created = await BlogClient.CreatePostAsync(draft);
|
||||
if (created is not null)
|
||||
{
|
||||
SelectedPost = created;
|
||||
StatusMessage = $"Created post {created.Id}.";
|
||||
}
|
||||
});
|
||||
StatusMessage = "Title is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
await ExecuteAsync(async () =>
|
||||
{
|
||||
if (SelectedPost.Id == 0)
|
||||
// Build a fresh BlogPost from the editor buffer on
|
||||
// every Save — we no longer mutate SelectedPost in
|
||||
// place. The previous behaviour copied the buffer
|
||||
// (which was a no-op when SelectedPost was null)
|
||||
// back onto the model and relied on a
|
||||
// [Required] violation to surface the missing
|
||||
// input; the new shape keeps the editor buffer as
|
||||
// the single source of truth for outgoing payloads
|
||||
// and the selected post as a read-only hint for
|
||||
// the update path.
|
||||
if (SelectedPost is null || SelectedPost.Id == 0)
|
||||
{
|
||||
SelectedPost.DateCreated = DateTime.UtcNow;
|
||||
SelectedPost.DateModified = DateTime.UtcNow;
|
||||
var created = await BlogClient.CreatePostAsync(SelectedPost);
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Title = DraftTitle,
|
||||
Article = DraftArticle ?? string.Empty,
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow,
|
||||
};
|
||||
var created = await BlogClient.CreatePostAsync(draft);
|
||||
if (created is not null)
|
||||
{
|
||||
SelectedPost = created;
|
||||
|
|
@ -163,8 +204,17 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
}
|
||||
else
|
||||
{
|
||||
SelectedPost.DateModified = DateTime.UtcNow;
|
||||
await BlogClient.UpdatePostAsync(SelectedPost.Id, SelectedPost);
|
||||
var update = new BlogPost
|
||||
{
|
||||
Id = SelectedPost.Id,
|
||||
AuthorId = SelectedPost.AuthorId,
|
||||
Photo = SelectedPost.Photo,
|
||||
Title = DraftTitle,
|
||||
Article = DraftArticle ?? string.Empty,
|
||||
DateCreated = SelectedPost.DateCreated,
|
||||
DateModified = DateTime.UtcNow,
|
||||
};
|
||||
await BlogClient.UpdatePostAsync(SelectedPost.Id, update);
|
||||
StatusMessage = $"Saved post {SelectedPost.Id}.";
|
||||
}
|
||||
|
||||
|
|
@ -256,6 +306,14 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
DeleteCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
private bool CanSave() => SelectedPost is not null && !IsBusy;
|
||||
/// <summary>Save is enabled as soon as the user has typed
|
||||
/// a non-whitespace title in the editor, regardless of
|
||||
/// whether a post is selected. The "no selection" case is
|
||||
/// the create-new-post path; the "with selection" case is
|
||||
/// the update path. Both read from the editor buffer.
|
||||
/// Previously this also required <c>SelectedPost is not null</c>
|
||||
/// — which contradicted the create-new-post intent and
|
||||
/// forced the buggy "draft with empty title" branch.</summary>
|
||||
private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle);
|
||||
private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,9 +79,9 @@
|
|||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
|
||||
<TextBox Grid.Row="1" Text="{Binding SelectedPost.Title, Mode=TwoWay}" PlaceholderText="Title" />
|
||||
<TextBox Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
|
||||
<AvaloniaEdit:TextEditor Grid.Row="2"
|
||||
views:TextEditorBinding.Text="{Binding SelectedPost.Article, Mode=TwoWay}"
|
||||
views:TextEditorBinding.Text="{Binding DraftArticle, Mode=TwoWay}"
|
||||
ShowLineNumbers="True"
|
||||
FontFamily="Cascadia Code, Consolas, Menlo, Monospace"
|
||||
MinHeight="320"
|
||||
|
|
|
|||
|
|
@ -242,4 +242,89 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
|||
using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync());
|
||||
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostBlog_from_PostIt_shape_returns_201_not_400()
|
||||
{
|
||||
// Regression test for the "Save" button in PostIt: from the
|
||||
// user's point of view, they type a Title and an Article in
|
||||
// the editor pane and tap "Save". The VM serialises the
|
||||
// SelectedPost via JsonContent.Create (camelCase, System.Text.Json
|
||||
// defaults) and POSTs it to /api/v1/blog. This test sends
|
||||
// exactly that payload — same fields, same types, same
|
||||
// serialiser (PostAsJsonAsync is wired to the same
|
||||
// System.Net.Http.Json pipeline that YavscApiClient uses on
|
||||
// the PostIt side) — and asserts that the server accepts it
|
||||
// with 201 Created, not 400 BadRequest. If the controller's
|
||||
// ModelState validation starts rejecting the PostIt payload
|
||||
// (missing field, wrong casing, etc.), this test fails
|
||||
// before the regression reaches a user.
|
||||
ResetDatabase();
|
||||
using var http = NewClient(subject: "tester");
|
||||
|
||||
// Mirrors what MainPageViewModel.Save builds: a BlogPost with
|
||||
// Id=0 (so the controller treats it as a create), Title and
|
||||
// Article filled in by the user, and DateCreated/DateModified
|
||||
// stamped by the VM. AuthorId is what the OIDC sub resolves
|
||||
// to in the test fixture.
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "Mon premier billet",
|
||||
AuthorId = "tester",
|
||||
Article = "Contenu du billet de test.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var response = await http.PostAsJsonAsync("/api/v1/blog", draft);
|
||||
|
||||
// Dump the body on failure so the test name + the response
|
||||
// payload are enough to start a fix; the framework's
|
||||
// assertion message is otherwise opaque (just "Expected
|
||||
// Created, got BadRequest").
|
||||
if (response.StatusCode != HttpStatusCode.Created)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Fail(string.Format("Expected 201 Created, got {0} {1}. Body: {2}", (int)response.StatusCode, response.StatusCode, body));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostBlog_with_empty_title_returns_400()
|
||||
{
|
||||
// Mirrors the buggy branch in MainPageViewModel.Save: when
|
||||
// the user taps "Save" without a SelectedPost (e.g. they
|
||||
// typed into the editor without first clicking an item in
|
||||
// the list, so the {Binding SelectedPost.Title, Mode=TwoWay}
|
||||
// XAML binding had no target and the keystrokes were
|
||||
// silently dropped), the VM builds a BlogPost with
|
||||
// Title = string.Empty and POSTs it. BlogPost.Title carries
|
||||
// [Required] → ModelState.IsValid fails → 400 BadRequest.
|
||||
// This is the regression we are hunting. The 400 is
|
||||
// expected here: the test pins the *current* controller
|
||||
// behaviour so a future change that, say, makes Title
|
||||
// nullable in the model or drops [Required], triggers a
|
||||
// conscious update of the test (and probably of the VM).
|
||||
ResetDatabase();
|
||||
using var http = NewClient(subject: "tester");
|
||||
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = string.Empty,
|
||||
AuthorId = "tester",
|
||||
Article = "Article non vide, mais titre vide.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var response = await http.PostAsJsonAsync("/api/v1/blog", draft);
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.BadRequest)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Fail(string.Format("Expected 400 BadRequest (empty Title is invalid), got {0} {1}. Body: {2}", (int)response.StatusCode, response.StatusCode, body));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue