Skip to content

Commit

Permalink
Implemented client side article editor (#6)
Browse files Browse the repository at this point in the history
* started implementing article API, missing lots of tests to validate feature

* made tests more pretty

* re-structured tests

* refactored dto contracts

* tested and fixed updating categories

* added permission tests, fixed bug in Permissions system

* added data validation tests for update article

* refactored repository interface

* Added ArticleView dto, fixed bug in requesting articles over repository

* updated dependencies

* optimized program.cs, added repo service

* Removed all interactivity from ArticleEditor, merged files

* added vite, tailwind working, dev server is not, js is not yet

* added fontsource for font management using vite's bundling

* moved vite output to wwwroot/dist
reorganized stuff that will never need processing or needs to be at site root

* fixed heading font weight not being 700 anymore

* implemented react in ArticleEditor

* added article status steps to react component
noticed I need to figure out client side localization

* fixed vite dev server thingies, tailwind and react refresh works now

* added article form skeletton to react

* more editor implementations

* minor typescript fixes

* implemented proper editor functions

* added all missing toolbar buttons

* fixed error, made open article work

* improved article editor structure

* implemented article editor taking id from the url

* Implemented categories endpoint

* implemented categories in article editor

* fixed minor TS issues

* implemented localization in article editor

* completed localization

* implemented loading selected categories

* minor code improvements and maybe a regex fix

* fixed bug with not getting unpublished articles

* implemented form state

* fixed validation issues

* implemented saving (missing creation)

* fixed minor bug with status display

* organized models

* added live markdown preview (incomplete)

* fixed issues in article create api endpoint

* improved article saving, implemented creating

* fixed publish date not being set correctly when creating article

* fixed slugs once more

* added run config for production (without vite dev)

* removed unused code

* updated dockerfile to build Assets

* fixed slug generation

* updated tests to validate new slug generator

* savsdSACAVSD

* fixed validation issues and tests
  • Loading branch information
miawinter98 authored Jun 18, 2024
1 parent 5c62ee4 commit 1d55ab2
Show file tree
Hide file tree
Showing 73 changed files with 7,745 additions and 5,223 deletions.
26 changes: 2 additions & 24 deletions Wave.Tests/Data/ApplicationDbContextTest.cs
Original file line number Diff line number Diff line change
@@ -1,34 +1,12 @@
using Microsoft.EntityFrameworkCore;
using Testcontainers.PostgreSql;
using Wave.Data;
using Wave.Tests.TestUtilities;

namespace Wave.Tests.Data;

[TestFixture, FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
[TestOf(typeof(ApplicationDbContext))]
public class ApplicationDbContextTest {
private PostgreSqlContainer PostgresContainer { get; } = new PostgreSqlBuilder().WithImage("postgres:16.1-alpine").Build();

[SetUp]
public async Task SetUp() {
await PostgresContainer.StartAsync();
}

[TearDown]
public async Task TearDown() {
await PostgresContainer.DisposeAsync();
}

private ApplicationDbContext GetContext() {
return new ApplicationDbContext(
new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql(PostgresContainer.GetConnectionString())
.EnableSensitiveDataLogging()
.EnableDetailedErrors()
.EnableThreadSafetyChecks()
.Options);
}

public class ApplicationDbContextTest : DbContextTest {
[Test]
public async Task Migration() {
await using var context = GetContext();
Expand Down
353 changes: 353 additions & 0 deletions Wave.Tests/Data/ApplicationRepositoryTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,353 @@
using System.Security.Claims;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Wave.Data;
using Wave.Data.Transactional;
using Wave.Tests.TestUtilities;

// ReSharper disable InconsistentNaming

namespace Wave.Tests.Data;

public abstract class ApplicationRepositoryTests : DbContextTest {
protected ApplicationRepository Repository { get; set; } = null!;

protected const string TestUserName = "[email protected]";
protected const string AuthorUserName = "[email protected]";
protected const string ReviewerUserName = "[email protected]";

protected ClaimsPrincipal AnonymousPrincipal { get; set; } = null!;
protected ClaimsPrincipal UserPrincipal { get; set; } = null!;
protected ClaimsPrincipal AuthorPrincipal { get; set; } = null!;
protected ClaimsPrincipal ReviewerPrincipal { get; set; } = null!;

protected Guid PrimaryCategoryId { get; set; }
protected Guid SecondaryCategoryId { get; set; }

protected virtual ValueTask InitializeTestEntities(ApplicationDbContext context) {
return ValueTask.CompletedTask;
}

protected override async ValueTask AndThenSetUp() {
Repository = new ApplicationRepository(new MockDbContextFactory(GetContext));

List<Category> categories = [
new Category {
Name = "Primary Category",
Color = CategoryColors.Primary
},
new Category {
Name = "Secondary Category",
Color = CategoryColors.Secondary
}
];

await using var context = GetContext();
var user = new ApplicationUser {
UserName = TestUserName
};
var author = new ApplicationUser {
UserName = AuthorUserName
};
var reviewer = new ApplicationUser {
UserName = ReviewerUserName
};

context.AddRange(categories);
context.Users.AddRange([user, author, reviewer]);

await context.Database.EnsureCreatedAsync();
await context.SaveChangesAsync();

AnonymousPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
UserPrincipal = new ClaimsPrincipal(new ClaimsIdentity([
new Claim(ClaimTypes.Name, user.UserName),
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim("Id", user.Id),
], "Mock Authentication"));
AuthorPrincipal = new ClaimsPrincipal(new ClaimsIdentity([
new Claim(ClaimTypes.Name, author.UserName),
new Claim(ClaimTypes.NameIdentifier, author.Id),
new Claim("Id", author.Id),
new Claim(ClaimTypes.Role, "Author"),
], "Mock Authentication"));
ReviewerPrincipal = new ClaimsPrincipal(new ClaimsIdentity([
new Claim(ClaimTypes.Name, reviewer.UserName),
new Claim(ClaimTypes.NameIdentifier, reviewer.Id),
new Claim("Id", reviewer.Id),
new Claim(ClaimTypes.Role, "Author"),
new Claim(ClaimTypes.Role, "Reviewer"),
], "Mock Authentication"));

PrimaryCategoryId = categories[0].Id;
SecondaryCategoryId = categories[1].Id;

await InitializeTestEntities(context);
}

private sealed class MockDbContextFactory(Func<ApplicationDbContext> supplier)
: IDbContextFactory<ApplicationDbContext> {
public ApplicationDbContext CreateDbContext() => supplier();
}
}

[TestFixture, FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
[TestOf(typeof(ApplicationRepository))]
public class ApplicationRepositoryTest_GetCategories : ApplicationRepositoryTests {

#region Success Tests

[Test]
public async Task AnonymousDefaultOneArticleOneCategory_Success() {
await Repository.CreateArticleAsync(
new ArticleCreateDto("test", "*test*", null, null, [PrimaryCategoryId], null), AuthorPrincipal);

var result = await Repository.GetCategories(AnonymousPrincipal);
Assert.Multiple(() => {
Assert.That(result, Is.Not.Empty);
Assert.That(result.First().Id, Is.EqualTo(PrimaryCategoryId));
Assert.That(result, Has.Count.EqualTo(1));
});
}

#endregion

#region Permission Tests

[Test]
public async Task AnonymousDefaultNoArticles_Success() {
var result = await Repository.GetCategories(AnonymousPrincipal);

Assert.Multiple(() => { Assert.That(result, Is.Empty); });
}

[Test]
public void AnonymousNoArticlesAllCategories_ThrowsMissingPermissions() {
Assert.ThrowsAsync<ApplicationException>(async () => await Repository.GetCategories(AnonymousPrincipal, true));
}

[Test]
public async Task AuthorDefaultNoArticles_Success() {
var result = await Repository.GetCategories(AuthorPrincipal);

Assert.Multiple(() => { Assert.That(result, Is.Empty); });
}

[Test]
public async Task AuthorDefaultNoArticlesAllCategories_Success() {
var result = await Repository.GetCategories(AuthorPrincipal, true);

Assert.Multiple(() => {
Assert.That(result, Is.Not.Empty);
Assert.That(result, Has.Count.EqualTo(2));
Assert.That(result.FirstOrDefault(c => c.Id == PrimaryCategoryId), Is.Not.Null);
Assert.That(result.FirstOrDefault(c => c.Id == SecondaryCategoryId), Is.Not.Null);
});
}

#endregion

}

[TestFixture, FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
[TestOf(typeof(ApplicationRepository))]
public class ApplicationRepositoryTest_CreateArticle : ApplicationRepositoryTests {
private static ArticleCreateDto GetValidTestArticle(Guid[]? categories = null) {
return new ArticleCreateDto(
"Test Article",
"*Test* Body",
null, null, categories, null);
}

#region Success Tests

[Test]
public async Task MinimalArticle_Success() {
var article = GetValidTestArticle();

var view = await Repository.CreateArticleAsync(article, AuthorPrincipal);

await using var context = GetContext();
Assert.Multiple(() => {
Assert.That(context.Set<Article>().IgnoreQueryFilters().ToList(), Has.Count.EqualTo(1));
Assert.That(context.Set<Article>().IgnoreQueryFilters().First().Id, Is.EqualTo(view.Id));
Assert.That(view.Status, Is.EqualTo(ArticleStatus.Draft));
Assert.That(view.BodyHtml, Is.Not.Null);
Assert.That(view.BodyPlain, Is.Not.Null);
Assert.That(view.Slug, Is.EqualTo("test-article"));
});
}

[Test]
public async Task WithCategories_Success() {
var article = GetValidTestArticle([PrimaryCategoryId]);
var view = await Repository.CreateArticleAsync(article, AuthorPrincipal);

await using var context = GetContext();
Assert.Multiple(() => {
Assert.That(view.Categories, Has.Count.EqualTo(1));
Assert.That(context.Set<Article>().IgnoreQueryFilters()
.Include(a => a.Categories)
.First(a => a.Id == view.Id).Categories.First().Id, Is.EqualTo(PrimaryCategoryId));
});
}

#endregion

#region Permission Tests

[Test]
public void RegularUser_ThrowsMissingPermissions() {
var article = GetValidTestArticle();

Assert.ThrowsAsync<ArticleMissingPermissionsException>(
async () => await Repository.CreateArticleAsync(article, UserPrincipal));
}

[Test]
public void AnonymousUser_ThrowsMissingPermissions() {
var article = GetValidTestArticle();

Assert.ThrowsAsync<ArticleMissingPermissionsException>(
async () => await Repository.CreateArticleAsync(article, AnonymousPrincipal));
}

#endregion

#region Data Validation Tests

[Test]
public void MissingTitle_ThrowsMalformed() {
var article = new ArticleCreateDto(null!, "test", null, null, null, null);

Assert.ThrowsAsync<ArticleMalformedException>(
async () => await Repository.CreateArticleAsync(article, AuthorPrincipal));
}

#endregion

}

[TestFixture, FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
[TestOf(typeof(ApplicationRepository))]
public class ApplicationRepositoryTest_UpdateArticle : ApplicationRepositoryTests {
private Guid TestArticleId { get; set; }

private ArticleUpdateDto GetValidTestArticle() => new(TestArticleId);

private static string StringOfLength(int length) {
var builder = new StringBuilder();

for (int i = 0; i < length; i++) {
builder.Append('_');
}

return builder.ToString();
}

protected override async ValueTask InitializeTestEntities(ApplicationDbContext context) {
var testArticle = new ArticleCreateDto(
"Test Article",
"Test **Article** with *formatting.",
"test-article",
DateTimeOffset.Now.AddHours(-5),
[PrimaryCategoryId], null);

var view = await Repository.CreateArticleAsync(testArticle, AuthorPrincipal);
TestArticleId = view.Id;
}

#region Success Tests

[Test]
public async Task UpdateTitle_Success() {
var update = new ArticleUpdateDto(TestArticleId, "New Title");

await Repository.UpdateArticleAsync(update, AuthorPrincipal);

await using var context = GetContext();
Assert.Multiple(() => {
Assert.That(context.Set<Article>().IgnoreQueryFilters().First(a => a.Id == TestArticleId).Title,
Is.EqualTo("New Title"));
});
}

[Test]
public async Task UpdateBodyUpdatesHtmlAndPlain_Success() {
var update = new ArticleUpdateDto(TestArticleId, body:"Some *new* Body");
const string expectedHtml = "<p>Some <em>new</em> Body</p>";
const string expectedPlain = "Some new Body";

await Repository.UpdateArticleAsync(update, AuthorPrincipal);

await using var context = GetContext();
Assert.Multiple(() => {
var article = context.Set<Article>().IgnoreQueryFilters().First(a => a.Id == TestArticleId);
Assert.That(article.BodyHtml, Is.EqualTo(expectedHtml));
Assert.That(article.BodyPlain, Is.EqualTo(expectedPlain));
});
}

[Test]
public async Task UpdateCategories_Success() {
var update = new ArticleUpdateDto(TestArticleId, categories:[SecondaryCategoryId]);
await Repository.UpdateArticleAsync(update, AuthorPrincipal);

await using var context = GetContext();
Assert.Multiple(() => {
var article = context.Set<Article>().IgnoreQueryFilters()
.Include(a => a.Categories).First(a => a.Id == TestArticleId);
Assert.That(article.Categories, Has.Count.EqualTo(1));
Assert.That(article.Categories.First().Id, Is.EqualTo(SecondaryCategoryId));
});
}

#endregion

#region Permission Tests

[Test]
public void AnonymousUser_ThrowsMissingPermissions() {
var update = GetValidTestArticle();

Assert.ThrowsAsync<ArticleMissingPermissionsException>(
async () => await Repository.UpdateArticleAsync(update, AnonymousPrincipal));
}

[Test]
public void RegularUser_ThrowsMissingPermissions() {
var update = GetValidTestArticle();

Assert.ThrowsAsync<ArticleMissingPermissionsException>(
async () => await Repository.UpdateArticleAsync(update, UserPrincipal));
}

[Test]
public void UnrelatedAuthor_ThrowsMissingPermissions() {
var update = GetValidTestArticle();

Assert.ThrowsAsync<ArticleMissingPermissionsException>(
async () => await Repository.UpdateArticleAsync(update, ReviewerPrincipal));
}

#endregion

#region Data Validation Tests

[Test]
public void SlugLength65_ThrowsMalformed() {
var update = new ArticleUpdateDto(TestArticleId, slug:StringOfLength(65));
Assert.ThrowsAsync<ArticleMalformedException>(
async () => await Repository.UpdateArticleAsync(update, AuthorPrincipal));
}

[Test]
public void TitleLength257_ThrowsMalformed() {
var update = new ArticleUpdateDto(TestArticleId, slug:StringOfLength(257));
Assert.ThrowsAsync<ArticleMalformedException>(
async () => await Repository.UpdateArticleAsync(update, AuthorPrincipal));
}

#endregion

}
Loading

0 comments on commit 1d55ab2

Please sign in to comment.