using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Client;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Server.Helpers;
using Yavsc.Tests.Shared;
using Yavsc.Blogs.Tests.Fixtures;
using BlogPostDto = Yavsc.Blogspot.BlogPostDto;
namespace Yavsc.Blogs.Tests;
///
/// Behavioural tests for BlogApiController. Built on the
/// scaffold: in-memory
/// ApplicationDbContext, real BlogSpotService, and a
/// real AddJwtBearer validating HS256 tokens signed by
/// . The production BlogScope
/// policy runs unmodified — sending Authorization: Bearer …
/// with a valid token is what gets a request through, omitting the
/// header (or sending a token signed with the wrong key) gets a
/// 401 back from the framework.
///
[Collection("Yavsc Blogs")]
public sealed class BlogApiTests : IClassFixture
{
private readonly BlogsWebServerFixture _fixture;
public BlogApiTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
/// Reset the database and seed the
/// tester row. Required
/// for any test that POST/PUT/DELETE a BlogPost:
/// BlogPost.AuthorId is a FK to
/// AspNetUsers.Id, and SQLite (unlike the EF Core
/// InMemory provider) enforces it. Without the seed, the
/// POST handler hits
/// SQLite Error 19: 'FOREIGN KEY constraint failed'
/// at SaveChanges and the controller returns 500.
private void ResetAndSeedDefaultUser()
{
_fixture.ResetDatabase();
_fixture.SeedUser("tester");
}
/// Build an authenticated client: a real
/// Authorization: Bearer <jwt> header where the JWT
/// is signed by and carries
/// sub = subject. The production BlogScope policy
/// reads scope=blogs off the same token, so
/// TestTokenIssuer.Issue's default scope is enough.
private HttpClient NewClient(string subject = "tester")
{
// The fixture's self-signed certificate is not in the user's
// trust store, so we accept anything (same pattern as
// Yavsc.Org.Tests' BypassSslValidationHandler).
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
};
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", TestTokenIssuer.Issue(subject));
return http;
}
/// Build an unauthenticated client. Used to assert that
/// the BlogScope policy fails closed when no bearer
/// token is presented.
private HttpClient NewAnonymousClient()
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
return new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
};
}
private int CountAttachmentsForPost(long postId)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService();
return db.BlogAttachedFiles.Count(a => a.PostId == postId);
}
[Fact]
public async Task GetBlogs_returns_200_with_empty_list_when_no_posts()
{
_fixture.ResetDatabase();
using var http = NewClient();
var response = await http.GetAsync(
_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
// Empty table → empty JSON array. We compare as a JsonDocument
// so a future change in formatting (whitespace, indentation)
// doesn't break the assertion.
using var doc = JsonDocument.Parse(body);
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
[Fact]
public async Task PostBlog_creates_a_post_and_Get_returns_it_in_the_list()
{
ResetAndSeedDefaultUser();
using var http = NewClient();
// Create a minimal BlogPost. The server assigns Id, so we
// send 0 + an explicit AuthorId; the production
// BlogSpotService.Create() tolerates that.
var draft = new BlogPost
{
Id = 0,
Title = "Premier billet",
AuthorId = "tester",
Article = "Contenu de test.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
// The POST returns the server-issued post (with a real Id).
var created = await postResponse.Content.ReadFromJsonAsync(
TestContext.Current.CancellationToken
);
Assert.NotNull(created);
Assert.NotEqual(0, created!.Id);
Assert.Equal(draft.Title, created.Title);
// The list should now contain exactly one entry.
var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
));
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(1, doc.RootElement.GetArrayLength());
Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64());
}
[Fact]
public async Task PostBlog_sets_AuthorId_on_created_post_and_list_entry()
{
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
var draft = new BlogPost
{
Id = 0,
Title = "Billet avec auteur",
AuthorId = "payload-attacker",
Article = "Contenu de test.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var created = await postResponse.Content.ReadFromJsonAsync(
TestContext.Current.CancellationToken
);
Assert.NotNull(created);
Assert.Equal("tester", created!.AuthorId);
var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
));
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(1, doc.RootElement.GetArrayLength());
Assert.Equal("tester", doc.RootElement[0].GetProperty("authorId").GetString());
}
[Fact]
public async Task PostBlogComment_returns_201_for_existing_post()
{
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
var draft = new BlogPost
{
Id = 0,
Title = "Billet commentable",
AuthorId = "payload-attacker",
Article = "Contenu de test.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var createdPost = await postResponse.Content.ReadFromJsonAsync(
TestContext.Current.CancellationToken
);
Assert.NotNull(createdPost);
Thread.Sleep(100);
var commentResponse = await http.PostAsJsonAsync("/api/v1/blogcomments", new
{
Article = "Premier commentaire",
ReceiverId = createdPost!.Id
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, commentResponse.StatusCode);
using var doc = JsonDocument.Parse(
await commentResponse.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
));
Assert.True(doc.RootElement.TryGetProperty("id", out var id));
Assert.True(id.GetInt64() > 0);
Assert.True(doc.RootElement.TryGetProperty("dateCreated", out _));
}
[Fact]
public void GetUserId_reads_NameIdentifier_when_sub_was_mapped()
{
var principal = new ClaimsPrincipal(
new ClaimsIdentity(
[new Claim(ClaimTypes.NameIdentifier, "tester")],
authenticationType: "Bearer"));
Assert.Equal("tester", principal.GetUserId());
}
[Fact]
public async Task GetBlog_returns_401_when_no_token_is_provided()
{
_fixture.ResetDatabase();
using var http = NewAnonymousClient();
// No Authorization header → the JwtBearer middleware
// produces an unauthenticated principal, the BlogScope
// policy's RequireAuthenticatedUser requirement fails, and
// the framework returns 401. This is the proof that the
// production policy is wired in the test host and not
// short-circuited by a test-only auth bypass.
var response = await http.GetAsync(_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task PutBlog_with_valid_token_and_owner_returns_204_and_Get_reflects_update()
{
ResetAndSeedDefaultUser();
// The JWT's sub must match the post's AuthorId:
// PermissionHandler.IsOwner checks blog.AuthorId == user.GetUserId(),
// and UserHelpers.GetUserId reads "sub" off the principal.
// A mismatched sub → AuthorizationFailureException →
// Challenge() (401) from the controller. The 204 in this
// test is the proof that the real authorization chain
// accepted the request, end-to-end.
using var http = NewClient(subject: "tester");
// Seed a post we can update.
var draft = new BlogPost
{
Id = 0,
Title = "Avant",
AuthorId = "tester",
Article = "Contenu initial.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var created = (await postResponse.Content.ReadFromJsonAsync(
TestContext.Current.CancellationToken
))!;
// PUT with the server-issued Id; the controller rejects
// mismatched id/blog.Id with 400, so we keep them aligned.
var update = new BlogPost
{
Id = created.Id,
Title = "Après",
AuthorId = created.AuthorId,
Article = created.Article,
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow
};
var putResponse = await http.PutAsJsonAsync(_fixture.BlogSpotUrl()+$"/{created.Id}",
update,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
// The list should now reflect the new title.
var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
using var doc = JsonDocument.Parse(
await listResponse.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
));
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(1, doc.RootElement.GetArrayLength());
Assert.Equal("Après", doc.RootElement[0].GetProperty("title").GetString());
}
[Fact]
public async Task PutBlog_multipart_with_blog_and_file_returns_204_and_persists_attachment()
{
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
var previousRoot = "blog";
var tempRoot = Path.Combine(Path.GetTempPath(), "yavsc-blogs-tests-files-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
try
{
var draft = new BlogPost
{
Id = 0,
Title = "Initial",
AuthorId = "tester",
Article = "Contenu initial.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync(
_fixture.BlogSpotUrl(),
draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var created = (await postResponse.Content.ReadFromJsonAsync(
TestContext.Current.CancellationToken))!;
var update = new BlogPost
{
Id = created.Id,
Title = "Mis a jour via multipart",
AuthorId = created.AuthorId,
Article = "Contenu mis a jour.",
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow
};
var form = new MultipartFormDataContent();
form.Add(new StringContent(JsonSerializer.Serialize(update)), "blog");
var fileBytes = System.Text.Encoding.UTF8.GetBytes("payload test");
var fileContent = new ByteArrayContent(fileBytes);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
form.Add(fileContent, "file", "note.txt");
using var request = new HttpRequestMessage(
HttpMethod.Put,
_fixture.BlogSpotUrl() + $"/{created.Id}")
{
Content = form
};
var putResponse = await http.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
var detailsResponse = await http.GetAsync(
_fixture.BlogSpotUrl() + $"/{created.Id}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, detailsResponse.StatusCode);
using var detailsDoc = JsonDocument.Parse(
await detailsResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.Equal("Mis a jour via multipart", detailsDoc.RootElement.GetProperty("title").GetString());
Assert.True(CountAttachmentsForPost(created.Id) >= 1);
}
finally
{
try { Directory.Delete(tempRoot, recursive: true); } catch { }
}
}
[Fact]
public async Task PutBlog_multipart_without_blog_field_returns_400()
{
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
var draft = new BlogPost
{
Id = 0,
Title = "Initial",
AuthorId = "tester",
Article = "Contenu initial.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync(
_fixture.BlogSpotUrl(),
draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var created = (await postResponse.Content.ReadFromJsonAsync(
TestContext.Current.CancellationToken))!;
var form = new MultipartFormDataContent();
var fileBytes = System.Text.Encoding.UTF8.GetBytes("payload test");
var fileContent = new ByteArrayContent(fileBytes);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
form.Add(fileContent, "file", "note.txt");
using var request = new HttpRequestMessage(
HttpMethod.Put,
_fixture.BlogSpotUrl() + $"/{created.Id}")
{
Content = form
};
var putResponse = await http.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, putResponse.StatusCode);
}
[Fact]
public async Task PutBlog_from_PostIt_client_with_attachment_returns_204_and_persists_attachment()
{
// Full PostIt "Save" flow for an existing post with a new
// attachment, driven through the real BlogApiClient — the
// same code PostIt runs — so any drift between the
// client's wire shape and the controller's contract fails
// here before it reaches a user:
//
// 1. BlogsViewModel.SaveAsync (update branch) calls
// BlogApiClient.UpdatePostAsync(id, update, attachments)
// with a non-empty attachments list →
// multipart/form-data PUT built by
// CreateMultipartContent: a "blog" field holding the
// BlogPostDto serialised camelCase + WhenWritingNull,
// plus one "file" part per attachment.
// 2. TryAppendAttachmentLinks then appends a markdown
// link per attachment to the article, and the VM
// issues a second UpdatePostAsync without files →
// plain JSON PUT.
//
// The test asserts the final state: the article carries
// the markdown link, the attachment row is persisted, and
// the file physically landed under the user-files root
// the link points to.
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
var previousRoot = "blog";
var tempRoot = Path.Combine(Path.GetTempPath(), "yavsc-blogs-tests-files-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
try
{
// BlogApiClient takes the versioned base address
// (…/api/v1/) and prepends its "blogspot" prefix —
// exactly how PostIt wires it against production.
var client = new BlogApiClient(
new TestApiTransport(http),
_fixture.ApiUrl(""));
// Seed the post through the same path PostIt uses for
// a first Save on a new draft: CreatePostAsync without
// files → plain JSON POST.
var draft = new BlogPostDto
{
Title = "Avant PostIt",
Article = "Contenu initial.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var created = await client.CreatePostAsync(draft,
ct: TestContext.Current.CancellationToken);
Assert.NotNull(created);
Assert.NotEqual(0, created!.Id);
Assert.Equal("tester", created.AuthorId);
// Step 1 — the update DTO the VM builds from the
// editor buffer (SelectedPost is the read-only hint
// for Id, AuthorId, Photo and DateCreated), sent with
// one attachment → multipart PUT.
var update = new BlogPostDto
{
Id = created.Id,
AuthorId = created.AuthorId,
Photo = created.Photo,
Title = "Modifié depuis PostIt",
Article = "Contenu modifié.",
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow
};
var attachments = new[]
{
new BlogUploadFile(
"note.txt",
System.Text.Encoding.UTF8.GetBytes("payload test"),
"text/plain")
};
await client.UpdatePostAsync(created.Id, update, attachments,
TestContext.Current.CancellationToken);
// Step 2 — TryAppendAttachmentLinks appended a
// markdown link to the article (the payload does not
// embed Author, so the owner segment falls back to
// AuthorId), and the VM PUTs the article again,
// without files → JSON path of UpdatePostAsync.
var fileUrl = $"{Yavsc.Constants.UserFilesPath}/tester/blogs/{created.Id}/note.txt";
var linkUpdate = new BlogPostDto
{
Id = created.Id,
AuthorId = created.AuthorId,
Photo = created.Photo,
Title = update.Title,
Article = update.Article + $"\n\n- [note.txt]({fileUrl})",
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow
};
await client.UpdatePostAsync(created.Id, linkUpdate,
ct: TestContext.Current.CancellationToken);
// Final state, read back through the client.
var details = await client.GetPostAsync(created.Id,
TestContext.Current.CancellationToken);
Assert.NotNull(details);
Assert.Equal("Modifié depuis PostIt", details!.Title);
Assert.Contains($"- [note.txt]({fileUrl})", details.Article);
// The attachment row is persisted…
Assert.True(CountAttachmentsForPost(created.Id) >= 1);
// …and the file physically landed where the appended
// markdown link points:
// {UserFilesDirName}/{user}/blogs/{postId}/{fileName}.
var expectedFile = Path.Combine(
tempRoot, "tester", "blogs", created.Id.ToString(), "note.txt");
var written = Directory.Exists(tempRoot)
? string.Join(", ", Directory.EnumerateFiles(tempRoot, "*", SearchOption.AllDirectories))
: "";
Assert.True(File.Exists(expectedFile),
$"Expected uploaded file at {expectedFile}. Actually written: {written}");
}
finally
{
try { Directory.Delete(tempRoot, recursive: true); } catch { }
}
}
[Fact]
public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list()
{
ResetAndSeedDefaultUser();
using var http = NewClient();
// Seed a post we can delete.
var draft = new BlogPost
{
Id = 0,
Title = "À supprimer",
AuthorId = "tester",
Article = "Contenu.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
var created = (await postResponse.Content.ReadFromJsonAsync(
TestContext.Current.CancellationToken
))!;
var deleteResponse = await http.DeleteAsync(_fixture.BlogSpotUrl()+$"/{created.Id}",
TestContext.Current.CancellationToken
);
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
// The list should now be empty.
var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken);
String response = await listResponse.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
);
using var doc = JsonDocument.Parse(response);
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.
ResetAndSeedDefaultUser();
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(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
// 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(
TestContext.Current.CancellationToken
);
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).
_fixture.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(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
if (response.StatusCode != HttpStatusCode.BadRequest)
{
var body = await response.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
);
Assert.Fail(string.Format("Expected 400 BadRequest (empty Title is invalid), got {0} {1}. Body: {2}", (int)response.StatusCode, response.StatusCode, body));
}
}
}