Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d24244aae6 |
|||
|
2744619249 |
|||
|
da35ac0a83 |
|||
|
875a020e0d |
|||
|
eb347e6900 |
|||
|
c34941a4b9 |
|||
|
afbefae111 |
|||
|
29a5b8be8d |
|||
|
d7e274c93a |
|||
|
bc8ae74fc3 |
|||
|
b5fa7b9072 |
|||
|
4ec169ed71 |
|||
|
63b44b5a92 |
|||
|
96ff4af440 |
|||
|
79ba934bce |
|||
|
2765d3eef8 |
|||
|
446b595077 |
|||
|
f9d95fd98f |
|||
|
fae754f102 |
|||
|
8b4b6a7c53 |
|||
|
7ee0c30500 |
60 changed files with 1467 additions and 253 deletions
3
.github/copilot-instructions.md
vendored
Normal file
3
.github/copilot-instructions.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Conventions du projet
|
||||
|
||||
- Par defaut, mettre a jour [CHANGELOG.md](../CHANGELOG.md) a chaque commit, en respectant sa structure existante et en documentant les changements du commit. Si aucune entree n'est pertinente, expliquer explicitement cette exception avant de committer.
|
||||
21
.github/workflows/docker-publish-backend.yml
vendored
21
.github/workflows/docker-publish-backend.yml
vendored
|
|
@ -8,8 +8,7 @@ on:
|
|||
|
||||
jobs:
|
||||
production-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
# 1. Récupération du code
|
||||
- name: Check out the repo
|
||||
|
|
@ -25,8 +24,24 @@ jobs:
|
|||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
- name: Workloads restore
|
||||
run: dotnet workload restore
|
||||
- name: Test
|
||||
run: dotnet test --no-build --verbosity normal --filter="Category!=Platform-Android"
|
||||
env:
|
||||
TEST_FAILED: ""
|
||||
run: make test || export TEST_FAILED=$?
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: my-artifact
|
||||
path: test-reports/**/*
|
||||
- name: Fail if tests failed
|
||||
if: ${{ env.TEST_FAILED != '' && env.TEST_FAILED != '0' }}
|
||||
run: exit $TEST_FAILED
|
||||
|
||||
# 4. Build et Push de l'image de production finale
|
||||
- name: Build and push production image
|
||||
uses: docker/build-push-action@v7
|
||||
|
|
|
|||
47
CHANGELOG.md
47
CHANGELOG.md
|
|
@ -1,6 +1,53 @@
|
|||
# Changelog
|
||||
|
||||
## [1.0.8-rc25] - unstable
|
||||
|
||||
### Added
|
||||
|
||||
* [Yavsc.Api] Notification du performer à la création d'un rendez-vous
|
||||
depuis PostIt, selon ses préférences de contact, sans supprimer l'envoi Org.
|
||||
* [Yavsc.Api][Yavsc.Server] Notification du performer à la signature
|
||||
client d'un devis (JSON ou image), enregistrée pour l'accueil PostIt
|
||||
et transmise par e-mail et SignalR.
|
||||
* [Yavsc.Api] Nouveau point d'entrée `GET /api/v1/notifications/me`
|
||||
pour exposer les notifications publiques et ciblées à PostIt sur la
|
||||
Home page ; les notifications d'administration restent filtrées.
|
||||
* [PostIt] Affichage des notifications sur la page d'accueil, avec
|
||||
actualisation, indicateur de chargement et message d'erreur. Les
|
||||
notifications publiques sont accessibles sans connexion et la liste
|
||||
est rechargée lors d'un changement de session.
|
||||
|
||||
### Changed
|
||||
|
||||
* [PostIt] Insertion des liens de fichiers dès leur ajout au brouillon ;
|
||||
les liens provisoires des nouveaux billets sont résolus au premier
|
||||
enregistrement. Save conserve l'édition ouverte et est mis en valeur
|
||||
lorsque le titre, le corps ou les pièces jointes sont modifiés.
|
||||
|
||||
* [Projet] Instructions Copilot persistantes : mise à jour du changelog
|
||||
par défaut à chaque commit, avec justification explicite des exceptions.
|
||||
|
||||
### Fixed
|
||||
|
||||
* [PostIt.Tests] Les tests du bandeau de session utilisent le bandeau
|
||||
réellement monté dans la fenêtre headless, au lieu d'un contrôle détaché.
|
||||
Les fenêtres sont fermées après chaque test et les quatre boutons
|
||||
de navigation sont vérifiés.
|
||||
|
||||
* [PostIt] Les liens Markdown des pièces jointes utilisent désormais
|
||||
le même filtre de nom que l'upload serveur, avant l'encodage URL.
|
||||
Le libellé conserve le nom original, notamment ses accents.
|
||||
|
||||
* [Yavsc.Abstract][Yavsc.Blogs] Encodage des caractères non supportés
|
||||
dans les noms de fichiers téléversés avec `_` au lieu de `#`, pour
|
||||
éviter les fragments d'URL et permettre le téléchargement via les
|
||||
fichiers statiques, notamment pour les noms accentués.
|
||||
|
||||
* [Yavsc.Api][Yavsc.Blogs] Les appels API et la bande passante sont
|
||||
désormais enregistrés côté serveur pour les requêtes authentifiées :
|
||||
le middleware de télémétrie est maintenant branché après
|
||||
l'authentification, ce qui permet de comptabiliser `ApiCalls` et
|
||||
`BandwidthMb` au lieu d'enregistrer des zéros.
|
||||
|
||||
## [1.0.8-rc24] - unstable
|
||||
|
||||
|
|
|
|||
|
|
@ -1,36 +1,12 @@
|
|||
# Utilisation de l'image globale pour le build et l'exécution
|
||||
FROM pazof/yavsc-build-env:debian12-dotnet10-android36-v1 AS build-env
|
||||
FROM pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 AS build-env
|
||||
WORKDIR /src
|
||||
|
||||
|
||||
# 1. Copie de la solution
|
||||
COPY *.sln ./
|
||||
COPY Directory.*.props ./
|
||||
|
||||
# 2. Copie de l'intégralité des fichiers de projets (.csproj) pour la restauration
|
||||
COPY src/Yavsc.Org/*.csproj ./src/Yavsc.Org/
|
||||
COPY src/Yavsc.Abstract/*.csproj ./src/Yavsc.Abstract/
|
||||
COPY src/Yavsc.Server/*.csproj ./src/Yavsc.Server/
|
||||
COPY src/Yavsc.Api/*.csproj ./src/Yavsc.Api/
|
||||
COPY src/Yavsc.Blogs/*.csproj ./src/Yavsc.Blogs/
|
||||
COPY src/cli/*.csproj ./src/cli/
|
||||
COPY src/Yavsc.Org.Tests/*.csproj ./src/Yavsc.Org.Tests/
|
||||
|
||||
# Projets associés à PostIt
|
||||
COPY src/PostIt/PostIt/*.csproj ./src/PostIt/PostIt/
|
||||
COPY src/PostIt/PostIt.Android/*.csproj ./src/PostIt/PostIt.Android/
|
||||
COPY src/PostIt/PostIt.Browser/*.csproj ./src/PostIt/PostIt.Browser/
|
||||
COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
|
||||
|
||||
# 4. Copie de l'intégralité du code source
|
||||
COPY . .
|
||||
|
||||
RUN dotnet workload restore
|
||||
# 4. Restauration des dépendances pour tous les projets
|
||||
RUN dotnet restore
|
||||
|
||||
# 5. Copie de la totalité du code source (filtrée par votre .dockerignore)
|
||||
COPY . .
|
||||
|
||||
# 6. Compilation du serveur Web principal
|
||||
RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly
|
||||
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -5,8 +5,6 @@ DESTDIR=/tmp/yavsc
|
|||
APP_PATH=srv/www/yavsc
|
||||
APP_FULL_PATH=$(DESTDIR)/$(APP_PATH)
|
||||
|
||||
include .env
|
||||
|
||||
all:
|
||||
dotnet build --nologo
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
"CorsAllowedOrigins": [
|
||||
"https://preprod-api.$DOMAIN"
|
||||
],
|
||||
"Blog": "blog"
|
||||
"UserFilesRoot": "files"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
"EMail": "paul@pschneider.fr"
|
||||
},
|
||||
"Quota": 200000000,
|
||||
"Blog": "blog",
|
||||
"UserFilesRoot": "files",
|
||||
"Avatars": "avatars",
|
||||
"Bills": "bills",
|
||||
"TempDir": "temp",
|
||||
|
|
|
|||
|
|
@ -73,6 +73,14 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
|
|||
public override Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
{
|
||||
_recorder.Calls.Add((method, path, contentFactory));
|
||||
if (typeof(T) == typeof(BlogPostDto))
|
||||
return Task.FromResult((T)(object)new BlogPostDto
|
||||
{
|
||||
Id = 42,
|
||||
Title = "Mon premier billet",
|
||||
AuthorId = "tester",
|
||||
Article = "Contenu du billet de test.",
|
||||
});
|
||||
return Task.FromResult(default(T)!);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,13 @@ namespace PostIt.Tests;
|
|||
/// </summary>
|
||||
public class BlogAttachmentLinkTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority()
|
||||
[Theory]
|
||||
[InlineData("note.txt", "note.txt")]
|
||||
[InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF",
|
||||
"INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")]
|
||||
[InlineData("rapport final.pdf", "rapport%20final.pdf")]
|
||||
public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority(
|
||||
string originalName, string urlName)
|
||||
{
|
||||
const string authority = "https://idp.example.test";
|
||||
|
||||
|
|
@ -53,27 +58,59 @@ public class BlogAttachmentLinkTests
|
|||
vm.DraftTitle = "Après";
|
||||
vm.DraftArticle = "Contenu modifié.";
|
||||
vm.DraftAttachments.Add(new BlogUploadFile(
|
||||
"note.txt",
|
||||
originalName,
|
||||
System.Text.Encoding.UTF8.GetBytes("payload test"),
|
||||
"text/plain"));
|
||||
|
||||
var expectedUrl = $"{authority}/files/tester/blogs/42/{urlName}";
|
||||
Assert.Contains($"- [{originalName}]({expectedUrl})", vm.DraftArticle);
|
||||
Assert.Empty(recorder.Calls);
|
||||
var draftBeforeSave = vm.DraftArticle;
|
||||
|
||||
await vm.SaveAsync();
|
||||
|
||||
// The update branch issues two PUTs: the multipart one
|
||||
// carrying the file, then — because TryAppendAttachmentLinks
|
||||
// appended a link — a JSON one carrying the final article.
|
||||
var linkPut = recorder.Calls.LastOrDefault(
|
||||
c => c.method == HttpMethod.Put);
|
||||
var linkPut = Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put);
|
||||
Assert.NotEqual(default, linkPut);
|
||||
var sent = Assert.IsType<System.Func<HttpContent>>(linkPut.body);
|
||||
var multipartContent = Assert.IsType<MultipartFormDataContent>(sent());
|
||||
|
||||
var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt";
|
||||
var sentStringContent = multipartContent.First() as StringContent ;
|
||||
var sentStringContent = Assert.IsType<StringContent>(multipartContent.First());
|
||||
|
||||
var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Contains($"- [note.txt]({expectedUrl})", sentString);
|
||||
using var document = System.Text.Json.JsonDocument.Parse(sentString);
|
||||
var article = document.RootElement.GetProperty("article").GetString();
|
||||
Assert.Contains($"- [{originalName}]({expectedUrl})", article);
|
||||
// The regression shape: a relative, authority-less link.
|
||||
Assert.DoesNotContain("](/files/", sentString);
|
||||
Assert.DoesNotContain("](/files/", article);
|
||||
Assert.Equal(draftBeforeSave, vm.DraftArticle);
|
||||
Assert.NotNull(vm.SelectedPost);
|
||||
Assert.False(vm.IsDraftModified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task New_post_resolves_only_the_provisional_link_at_first_save()
|
||||
{
|
||||
var recorder = new CallRecorder();
|
||||
var api = new RecordingYavscApiClient(recorder);
|
||||
var vm = new BlogsViewModel(new BlogApiClient(api, "https://blogs.example.test/api/v1/"),
|
||||
new Settings { Authentication = new AuthenticationSettings { Authority = "https://idp.example.test" } });
|
||||
vm.DraftTitle = "Nouveau billet";
|
||||
vm.DraftArticle = "Texte conservé.";
|
||||
vm.DraftAttachments.Add(new BlogUploadFile("Troisi\u00e8me.PDF", new byte[] { 1 }));
|
||||
|
||||
Assert.Contains("postit-attachment:", vm.DraftArticle);
|
||||
Assert.Empty(recorder.Calls);
|
||||
await vm.SaveAsync();
|
||||
|
||||
Assert.Equal("Nouveau billet", vm.DraftTitle);
|
||||
Assert.Contains("Texte conservé.", vm.DraftArticle);
|
||||
Assert.Contains("- [Troisi\u00e8me.PDF](https://idp.example.test/files/tester/blogs/42/Troisi_232me.PDF)",
|
||||
vm.DraftArticle);
|
||||
Assert.DoesNotContain("postit-attachment:", vm.DraftArticle);
|
||||
Assert.Equal(42, vm.SelectedPost?.Id);
|
||||
Assert.Empty(vm.DraftAttachments);
|
||||
Assert.False(vm.IsDraftModified);
|
||||
Assert.Single(recorder.Calls, c => c.method == HttpMethod.Post);
|
||||
Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
199
src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs
Normal file
199
src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
using System.Net.Http;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.VisualTree;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using Yavsc.Abstract.Models.Messaging;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class HomePageNotificationsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Refresh_loads_public_notifications_from_the_business_host()
|
||||
{
|
||||
var transport = new NotificationTransport();
|
||||
var vm = CreateViewModel(transport);
|
||||
transport.Response = Task.FromResult(new List<Notification> { Note(1, "Information") });
|
||||
|
||||
await vm.RefreshNotificationsAsync();
|
||||
|
||||
Assert.Equal("https://business.example/api/v1/notifications", transport.LastPath);
|
||||
Assert.True(transport.WasAnonymous);
|
||||
Assert.Equal("Information", Assert.Single(vm.Notifications).title);
|
||||
Assert.False(vm.IsLoadingNotifications);
|
||||
Assert.False(vm.HasNoNotifications);
|
||||
Assert.Null(vm.NotificationsError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_replaces_notifications_and_distinguishes_empty_from_failed()
|
||||
{
|
||||
var transport = new NotificationTransport();
|
||||
var vm = CreateViewModel(transport);
|
||||
vm.Notifications.Add(Note(1, "Old"));
|
||||
await vm.RefreshNotificationsAsync();
|
||||
|
||||
Assert.Empty(vm.Notifications);
|
||||
Assert.True(vm.HasNoNotifications);
|
||||
|
||||
transport.Response = Task.FromException<List<Notification>>(new HttpRequestException("Server unavailable"));
|
||||
await vm.RefreshNotificationsAsync();
|
||||
|
||||
Assert.Empty(vm.Notifications);
|
||||
Assert.False(vm.HasNoNotifications);
|
||||
Assert.Contains("Server unavailable", vm.NotificationsError);
|
||||
Assert.False(vm.IsLoadingNotifications);
|
||||
|
||||
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Recovered") });
|
||||
await vm.RefreshNotificationsAsync();
|
||||
Assert.Null(vm.NotificationsError);
|
||||
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_ignores_an_older_response_after_a_newer_refresh()
|
||||
{
|
||||
var transport = new NotificationTransport();
|
||||
var vm = CreateViewModel(transport);
|
||||
var oldResponse = new TaskCompletionSource<List<Notification>>();
|
||||
transport.Response = oldResponse.Task;
|
||||
var oldLoad = vm.RefreshNotificationsAsync();
|
||||
Assert.True(vm.IsLoadingNotifications);
|
||||
|
||||
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Current") });
|
||||
await vm.RefreshNotificationsAsync();
|
||||
oldResponse.SetResult(new List<Notification> { Note(1, "Stale") });
|
||||
await oldLoad;
|
||||
|
||||
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
|
||||
Assert.False(vm.IsLoadingNotifications);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Login_and_logout_reload_notifications_and_discard_in_flight_private_data()
|
||||
{
|
||||
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-notifications-{Guid.NewGuid():N}.json");
|
||||
var settings = new Settings();
|
||||
var store = new TokenStore(tokenPath);
|
||||
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token",
|
||||
DateTimeOffset.UtcNow.AddHours(1), null));
|
||||
await using var api = new YavscApiClient(settings, store);
|
||||
try
|
||||
{
|
||||
var session = new SessionStatusViewModel { Api = api };
|
||||
var transport = new NotificationTransport();
|
||||
var vm = CreateViewModel(transport, session);
|
||||
session.Refresh();
|
||||
Assert.False(transport.WasAnonymous);
|
||||
Assert.EndsWith("/notifications/me", transport.LastPath);
|
||||
var privateResponse = new TaskCompletionSource<List<Notification>>();
|
||||
transport.Response = privateResponse.Task;
|
||||
var privateLoad = vm.RefreshNotificationsAsync();
|
||||
Assert.False(transport.WasAnonymous);
|
||||
Assert.EndsWith("/notifications/me", transport.LastPath);
|
||||
|
||||
vm.Notifications.Add(Note(9, "Private"));
|
||||
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Public") });
|
||||
session.SetError("Disconnected");
|
||||
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
|
||||
Assert.True(transport.WasAnonymous);
|
||||
|
||||
privateResponse.SetResult(new List<Notification> { Note(1, "Late private data") });
|
||||
await privateLoad;
|
||||
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tokenPath);
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies()
|
||||
{
|
||||
var transport = new NotificationTransport
|
||||
{
|
||||
Response = Task.FromResult(new List<Notification> { Note(1, "Notification title") })
|
||||
};
|
||||
var vm = CreateViewModel(transport);
|
||||
var page = new HomePage { DataContext = vm };
|
||||
var nav = new NavigationPage();
|
||||
var window = new Window { Content = nav, Width = 320, Height = 450 };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
await nav.PushAsync(page);
|
||||
window.UpdateLayout();
|
||||
|
||||
Assert.Single(vm.Notifications);
|
||||
var texts = page.GetVisualDescendants().OfType<TextBlock>().ToArray();
|
||||
Assert.Contains(texts, t => t.Text == "Notification title"
|
||||
&& t.TextWrapping == Avalonia.Media.TextWrapping.Wrap);
|
||||
Assert.Contains(texts, t => t.Text == "Notification body"
|
||||
&& t.TextWrapping == Avalonia.Media.TextWrapping.Wrap);
|
||||
var refresh = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
|
||||
b => b.Command == vm.RefreshNotificationsCommand);
|
||||
Assert.True(refresh.IsEnabled);
|
||||
Assert.Contains(page.GetVisualDescendants().OfType<ScrollViewer>(),
|
||||
s => s.Extent.Height > s.Viewport.Height);
|
||||
|
||||
await nav.PushAsync(new ContentPage());
|
||||
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Updated title") });
|
||||
await nav.PopAsync();
|
||||
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
|
||||
private static HomePageViewModel CreateViewModel(NotificationTransport transport,
|
||||
SessionStatusViewModel? session = null)
|
||||
=> new(null!, new Settings(), session ?? new SessionStatusViewModel(),
|
||||
new NotificationsApiClient(transport, "https://business.example/api/v1/"));
|
||||
|
||||
private static Notification Note(long id, string title)
|
||||
=> new() { Id = id, title = title, body = "Notification body", click_action = "Close" };
|
||||
|
||||
private sealed class NotificationTransport : IYavscApiClient
|
||||
{
|
||||
public HttpClient Http { get; } = new();
|
||||
public string? LastPath { get; private set; }
|
||||
public bool WasAnonymous { get; private set; }
|
||||
public Task<List<Notification>> Response { get; set; } = Task.FromResult(new List<Notification>());
|
||||
|
||||
public Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
|
||||
=> ReadAsync<T>(path, true);
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Assert.Equal(HttpMethod.Get, method);
|
||||
return ReadAsync<T>(path, false);
|
||||
}
|
||||
|
||||
private async Task<T> ReadAsync<T>(string path, bool anonymous)
|
||||
{
|
||||
LastPath = path;
|
||||
WasAnonymous = anonymous;
|
||||
return (T)(object)await Response;
|
||||
}
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory,
|
||||
CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task CallAsync(HttpMethod method, string path, object? body = null,
|
||||
CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory,
|
||||
CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Http.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,91 @@ namespace PostIt.Tests;
|
|||
|
||||
public class PostItViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Draft_modified_tracks_title_body_and_attachments_not_publication()
|
||||
{
|
||||
var vm = new BlogsViewModel(new BlogApiClient(new ThrowingYavscApiClient(), "http://localhost/"));
|
||||
vm.SelectedPost = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" };
|
||||
Assert.False(vm.IsDraftModified);
|
||||
vm.DraftIsPublished = true;
|
||||
Assert.False(vm.IsDraftModified);
|
||||
vm.DraftTitle = "Autre";
|
||||
Assert.True(vm.IsDraftModified);
|
||||
vm.DraftTitle = "Titre";
|
||||
Assert.False(vm.IsDraftModified);
|
||||
vm.DraftArticleDocument!.Insert(0, "Edit ");
|
||||
Assert.True(vm.IsDraftModified);
|
||||
vm.DraftArticle = "Corps";
|
||||
Assert.False(vm.IsDraftModified);
|
||||
vm.DraftAttachments.Add(new BlogUploadFile("note.txt", new byte[] { 1 }));
|
||||
Assert.True(vm.IsDraftModified);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Save_keeps_selection_and_editor_open_and_resets_highlight()
|
||||
{
|
||||
var recorder = new CallRecorder();
|
||||
var vm = new BlogsViewModel(new BlogApiClient(new RecordingYavscApiClient(recorder),
|
||||
"https://blogs.example.test/api/v1/"));
|
||||
var selected = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" };
|
||||
vm.Posts.Add(selected);
|
||||
vm.SearchText = "";
|
||||
vm.SelectedPost = selected;
|
||||
var page = new BlogsPage { DataContext = vm };
|
||||
var navigation = new NavigationPage();
|
||||
await navigation.PushAsync(page);
|
||||
var window = new Window { Content = navigation };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
selected = Assert.Single(vm.FilteredPosts);
|
||||
selected.AuthorId = "tester";
|
||||
selected.Title = "Titre";
|
||||
selected.Article = "Corps";
|
||||
page.PostsListBox.SelectedItem = selected;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.Same(selected, page.PostsListBox.SelectedItem);
|
||||
Assert.Same(selected, vm.SelectedPost);
|
||||
Assert.DoesNotContain("modified", page.SaveButton.Classes);
|
||||
vm.DraftTitle = "Titre modifié";
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.Contains("modified", page.SaveButton.Classes);
|
||||
Assert.Equal(Avalonia.Media.FontWeight.Bold, page.SaveButton.FontWeight);
|
||||
Assert.Same(vm.SaveCommand, page.SaveButton.Command);
|
||||
await vm.SaveAsync();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.Same(page, navigation.NavigationStack.Last());
|
||||
Assert.Same(selected, vm.SelectedPost);
|
||||
Assert.Same(selected, page.PostsListBox.SelectedItem);
|
||||
Assert.Equal("Titre modifié", vm.DraftTitle);
|
||||
Assert.Equal("Corps", vm.DraftArticle);
|
||||
Assert.DoesNotContain("modified", page.SaveButton.Classes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_save_preserves_draft_attachments_and_modified_state()
|
||||
{
|
||||
var vm = new BlogsViewModel(new BlogApiClient(new ThrowingYavscApiClient(), "http://localhost/"));
|
||||
var post = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" };
|
||||
vm.SelectedPost = post;
|
||||
vm.DraftTitle = "Titre modifié";
|
||||
vm.DraftAttachments.Add(new BlogUploadFile("note.txt", new byte[] { 1 }));
|
||||
var article = vm.DraftArticle;
|
||||
await vm.SaveAsync();
|
||||
Assert.Same(post, vm.SelectedPost);
|
||||
Assert.Equal("Titre modifié", vm.DraftTitle);
|
||||
Assert.Equal(article, vm.DraftArticle);
|
||||
Assert.Single(vm.DraftAttachments);
|
||||
Assert.True(vm.IsDraftModified);
|
||||
Assert.False(vm.IsBusy);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Published_badge_tracks_post_publication_state()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.VisualTree;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
|
|
@ -23,41 +22,36 @@ namespace PostIt.Tests;
|
|||
/// <c>new Window().Show()</c> throws because the harness has not
|
||||
/// been initialised for that thread.</para>
|
||||
///
|
||||
/// <para>The session banner's <c>DataContext</c> is not wired in
|
||||
/// these tests: <c>App.OnFrameworkInitializationCompleted</c> is
|
||||
/// not called in a unit test, so we set the DataContext on the
|
||||
/// banner directly. The production code path is exercised
|
||||
/// <para>These tests set the DataContext directly on the banner
|
||||
/// mounted in the window. The production code path is exercised
|
||||
/// end-to-end by the manual launch, not here.</para>
|
||||
/// </summary>
|
||||
public class SessionStatusBannerTests
|
||||
public class SessionStatusBannerTests : IDisposable
|
||||
{
|
||||
[AvaloniaFact]
|
||||
public void Banner_renders_three_buttons_in_the_visual_tree()
|
||||
{
|
||||
MainWindow window = new MainWindow();
|
||||
window.Show();
|
||||
private MainWindow? _window;
|
||||
|
||||
var buttons = window.GetVisualDescendants()
|
||||
[AvaloniaFact]
|
||||
public void Banner_renders_four_buttons_in_the_visual_tree()
|
||||
{
|
||||
SessionStatusBanner banner = CreateBanner();
|
||||
|
||||
var buttons = banner.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.ToList();
|
||||
|
||||
// Three buttons, named by their content text: Se
|
||||
// déconnecter, Se connecter, Paramètres. If any one is
|
||||
// missing, the user has no way to trigger the
|
||||
// corresponding navigation event.
|
||||
Assert.True(buttons.Count>=4);
|
||||
Assert.Equal(4, buttons.Count);
|
||||
Assert.Contains(buttons, b => b.Content as string == "Se déconnecter");
|
||||
Assert.Contains(buttons, b => b.Content as string == "Se connecter");
|
||||
Assert.Contains(buttons, b => b.Content as string == "Paramètres");
|
||||
Assert.Contains(buttons, b => b.Content as string == "À propos");
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Banner_login_button_is_visible_when_logged_out()
|
||||
{
|
||||
MainWindow window = new MainWindow();
|
||||
window.Show();
|
||||
SessionStatusBanner banner = CreateBanner();
|
||||
|
||||
var login = window.GetVisualDescendants()
|
||||
var login = banner.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.Single(b => b.Content as string == "Se connecter");
|
||||
|
||||
|
|
@ -69,12 +63,9 @@ public class SessionStatusBannerTests
|
|||
[AvaloniaFact]
|
||||
public void Banner_logout_button_is_hidden_when_logged_out()
|
||||
{
|
||||
SessionStatusBanner banner = CreateBanner();
|
||||
|
||||
Assert.False((banner.DataContext as SessionStatusViewModel)!
|
||||
.IsLoggedIn); // VM default
|
||||
|
||||
SessionStatusBanner banner = CreateBanner();
|
||||
|
||||
Assert.False(Assert.IsType<SessionStatusViewModel>(banner.DataContext).IsLoggedIn);
|
||||
var logout = banner.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.Single(b => b.Content as string == "Se déconnecter");
|
||||
|
|
@ -91,22 +82,23 @@ public class SessionStatusBannerTests
|
|||
.OfType<Button>()
|
||||
.Single(b => b.Content as string == "Paramètres");
|
||||
|
||||
// Paramètres is the only button with no IsVisible
|
||||
// binding — always shown. The user's only path to the
|
||||
// settings page goes through this button.
|
||||
// Paramètres has no IsVisible binding and is always shown.
|
||||
Assert.True(settings.IsVisible);
|
||||
}
|
||||
|
||||
private static SessionStatusBanner CreateBanner()
|
||||
private SessionStatusBanner CreateBanner()
|
||||
{
|
||||
MainWindow window = new MainWindow();
|
||||
window.Show();
|
||||
var banner = new SessionStatusBanner();
|
||||
var status = new SessionStatusViewModel();
|
||||
banner.DataContext = status;
|
||||
_window = new MainWindow();
|
||||
_window.Show();
|
||||
var banner = _window.GetVisualDescendants()
|
||||
.OfType<SessionStatusBanner>()
|
||||
.Single();
|
||||
banner.DataContext = new SessionStatusViewModel();
|
||||
return banner;
|
||||
}
|
||||
|
||||
public void Dispose() => _window?.Close();
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Banner_session_label_reflects_DataContext()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,6 +17,56 @@ namespace PostIt.Tests;
|
|||
/// </summary>
|
||||
public class YavscApiClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetAnonymousAsync_surfaces_http_errors_without_attempting_login_or_retry()
|
||||
{
|
||||
using var apiServer = new StubApiServer(forceFirstRequest: true);
|
||||
await apiServer.StartAsync();
|
||||
var tokensPath = TokensPath();
|
||||
try
|
||||
{
|
||||
await using var client = new YavscApiClient(new Settings(), new TokenStore(tokensPath));
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() =>
|
||||
client.GetAnonymousAsync<List<StubApiServer.Post>>(
|
||||
apiServer.BaseUrl + "/posts", TestContext.Current.CancellationToken));
|
||||
Assert.Equal(1, apiServer.RequestCount);
|
||||
Assert.Empty(apiServer.SeenBearers);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tokensPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task GetAnonymousAsync_does_not_require_or_send_session_credentials(bool hasSession)
|
||||
{
|
||||
using var apiServer = new StubApiServer();
|
||||
await apiServer.StartAsync();
|
||||
var tokensPath = TokensPath();
|
||||
try
|
||||
{
|
||||
var store = new TokenStore(tokensPath);
|
||||
if (hasSession)
|
||||
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token",
|
||||
DateTimeOffset.UtcNow.AddHours(1), null));
|
||||
await using var client = new YavscApiClient(new Settings(), store);
|
||||
|
||||
var posts = await client.GetAnonymousAsync<List<StubApiServer.Post>>(
|
||||
apiServer.BaseUrl + "/posts", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(2, posts.Count);
|
||||
Assert.Equal(1, apiServer.RequestCount);
|
||||
Assert.Empty(apiServer.SeenBearers);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tokensPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetFreePort()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
|
|
|
|||
|
|
@ -44,10 +44,6 @@ public partial class App : Application, ILiveView, IVMPusherApp
|
|||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
#if DEBUG
|
||||
this.AttachDeveloperTools();
|
||||
#endif
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ public static class ServiceCollectionHelpers
|
|||
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
|
||||
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
|
||||
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
|
||||
var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl);
|
||||
var userDirectory = new UserDirectory(userSearchClient);
|
||||
var reverseGeocoding = new NominatimReverseGeocodingService();
|
||||
|
||||
|
|
@ -93,6 +94,7 @@ public static class ServiceCollectionHelpers
|
|||
services.AddSingleton(estimateClient);
|
||||
services.AddSingleton(frontClient);
|
||||
services.AddSingleton(resourceUsageClient);
|
||||
services.AddSingleton(notificationsClient);
|
||||
services.AddSingleton(userFilesClient);
|
||||
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,16 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
||||
|
||||
private RefreshTokenRecord? _tokens;
|
||||
private static readonly HttpRequestOptionsKey<bool> AnonymousRequest = new("PostIt.AnonymousRequest");
|
||||
|
||||
public virtual async Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, path);
|
||||
request.Options.Set(AnonymousRequest, true);
|
||||
using var response = await Http.SendAsync(request, ct).ConfigureAwait(false);
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
return await ReadJsonAsync<T>(response, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
|
||||
{
|
||||
|
|
@ -614,7 +624,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_owner._tokens is not null)
|
||||
if (_owner._tokens is not null
|
||||
&& !(request.Options.TryGetValue(AnonymousRequest, out var anonymous) && anonymous))
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||
"Bearer", _owner._tokens.AccessToken);
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
|
|
@ -10,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||
using Yavsc.Blogspot;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Abstract.Files;
|
||||
using Yavsc.Server.Helpers;
|
||||
using AvaloniaEdit.Document;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
|
@ -88,12 +90,14 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
}
|
||||
|
||||
OnPropertyChanged(nameof(DraftArticleDocument));
|
||||
UpdateDraftModified();
|
||||
}
|
||||
}
|
||||
|
||||
private void DraftArticleDocument_TextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
OnPropertyChanged(nameof(DraftArticle));
|
||||
UpdateDraftModified();
|
||||
}
|
||||
|
||||
/// <summary>Editor buffer for the post's publication state.
|
||||
|
|
@ -107,6 +111,14 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
/// mutable field. Toggling is its own action.</summary>
|
||||
[ObservableProperty]
|
||||
public partial bool DraftIsPublished { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsDraftModified { get; private set; }
|
||||
|
||||
private bool _preserveDraft;
|
||||
private string _savedTitle = string.Empty;
|
||||
private string _savedArticle = string.Empty;
|
||||
private readonly Dictionary<string, string> _pendingAttachmentLinks = new();
|
||||
public bool IsLoaded { get; private set; }
|
||||
public Settings SettingsModel { get; }
|
||||
|
||||
|
|
@ -156,7 +168,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
});
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
[RelayCommand(CanExecute = nameof(CanSave))]
|
||||
internal async Task SaveAsync()
|
||||
{
|
||||
// The button is already disabled when the title is empty
|
||||
|
|
@ -174,16 +186,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
{
|
||||
var attachments = DraftAttachments.ToArray();
|
||||
|
||||
// Build a fresh BlogPostDto from the editor buffer on
|
||||
// every Save — we no longer mutate SelectedPost in
|
||||
// place. The previous behavior 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)
|
||||
{
|
||||
var draft = new BlogPostDto
|
||||
|
|
@ -194,12 +196,22 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
DateModified = DateTime.UtcNow,
|
||||
IsPublished = DraftIsPublished
|
||||
};
|
||||
var savedTitle = draft.Title;
|
||||
var savedArticle = draft.Article;
|
||||
var created = await BlogClient!.CreatePostAsync(draft, attachments);
|
||||
if (created is not null)
|
||||
{
|
||||
SelectedPost = created;
|
||||
_preserveDraft = true;
|
||||
try
|
||||
{
|
||||
SelectedPost = created;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_preserveDraft = false;
|
||||
}
|
||||
|
||||
if (TryAppendAttachmentLinks(created, attachments))
|
||||
if (ResolvePendingAttachmentLinks(created))
|
||||
{
|
||||
var linkUpdate = new BlogPostDto
|
||||
{
|
||||
|
|
@ -211,16 +223,22 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
DateCreated = created.DateCreated,
|
||||
DateModified = DateTime.UtcNow,
|
||||
};
|
||||
savedTitle = linkUpdate.Title;
|
||||
savedArticle = linkUpdate.Article;
|
||||
await BlogClient.UpdatePostAsync(created.Id, linkUpdate);
|
||||
}
|
||||
|
||||
created.Title = savedTitle;
|
||||
created.Article = savedArticle;
|
||||
this.SetInfoStatus($"Billet {created.Id} créé.");
|
||||
DraftAttachments.Clear();
|
||||
MarkDraftSaved(savedTitle, savedArticle);
|
||||
}
|
||||
else
|
||||
throw new InvalidOperationException("Le serveur n'a pas retourné le billet créé.");
|
||||
}
|
||||
else
|
||||
{
|
||||
TryAppendAttachmentLinks(SelectedPost, attachments);
|
||||
var update = new BlogPostDto
|
||||
{
|
||||
Id = SelectedPost.Id,
|
||||
|
|
@ -232,8 +250,11 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
DateModified = DateTime.UtcNow,
|
||||
};
|
||||
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments);
|
||||
SelectedPost.Title = update.Title;
|
||||
SelectedPost.Article = update.Article;
|
||||
this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré.");
|
||||
DraftAttachments.Clear();
|
||||
MarkDraftSaved(update.Title, update.Article);
|
||||
}
|
||||
|
||||
await RefreshPostsAsync();
|
||||
|
|
@ -429,6 +450,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
Posts = new ObservableCollection<BlogPostDto>();
|
||||
FilteredPosts = new ObservableCollection<BlogPostDto>();
|
||||
DraftAttachments = new ObservableCollection<BlogUploadFile>();
|
||||
DraftAttachments.CollectionChanged += DraftAttachments_CollectionChanged;
|
||||
SelectedPost = null;
|
||||
IsBusy = false;
|
||||
this.SetInfoStatus("Prêt.");
|
||||
|
|
@ -440,6 +462,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
DraftArticle = string.Empty;
|
||||
|
||||
DraftIsPublished = false;
|
||||
MarkDraftSaved();
|
||||
IsLoaded = false;
|
||||
// Production path: DI injects the canonical Settings singleton
|
||||
// and we use it as-is. Test path: tests call this constructor
|
||||
|
|
@ -500,6 +523,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
|
||||
partial void OnSelectedPostChanged(BlogPostDto? value)
|
||||
{
|
||||
if (_preserveDraft) return;
|
||||
// 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
|
||||
|
|
@ -513,6 +537,8 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
// null selection so a fresh draft starts unpublished.
|
||||
DraftIsPublished = value?.IsPublished ?? false;
|
||||
DraftAttachments.Clear();
|
||||
_pendingAttachmentLinks.Clear();
|
||||
MarkDraftSaved();
|
||||
UpdateCommandStates();
|
||||
}
|
||||
|
||||
|
|
@ -521,7 +547,28 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
// 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 OnDraftTitleChanged(string value)
|
||||
{
|
||||
SaveCommand.NotifyCanExecuteChanged();
|
||||
UpdateDraftModified();
|
||||
}
|
||||
|
||||
private void UpdateDraftModified()
|
||||
{
|
||||
IsDraftModified = !string.Equals(DraftTitle ?? string.Empty, _savedTitle, StringComparison.Ordinal)
|
||||
|| !string.Equals(DraftArticle ?? string.Empty, _savedArticle, StringComparison.Ordinal)
|
||||
|| (DraftAttachments?.Count ?? 0) > 0;
|
||||
}
|
||||
|
||||
private void MarkDraftSaved()
|
||||
=> MarkDraftSaved(DraftTitle, DraftArticle);
|
||||
|
||||
private void MarkDraftSaved(string? title, string? article)
|
||||
{
|
||||
_savedTitle = title ?? string.Empty;
|
||||
_savedArticle = article ?? string.Empty;
|
||||
UpdateDraftModified();
|
||||
}
|
||||
|
||||
|
||||
private async Task RefreshPostsAsync()
|
||||
|
|
@ -534,10 +581,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
}
|
||||
ApplyFilter();
|
||||
|
||||
if (SelectedPost is not null)
|
||||
{
|
||||
SelectedPost = Posts.FirstOrDefault(post => post.Id == SelectedPost.Id) ?? SelectedPost;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFilter()
|
||||
|
|
@ -552,10 +595,29 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
|| p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true)
|
||||
.OrderByDescending(p => p.DateModified);
|
||||
|
||||
FilteredPosts.Clear();
|
||||
foreach (var post in filtered)
|
||||
var selection = SelectedPost;
|
||||
var visiblePosts = filtered.Select(post => post.Id == selection?.Id ? selection : post).ToArray();
|
||||
_preserveDraft = true;
|
||||
try
|
||||
{
|
||||
FilteredPosts.Add(post);
|
||||
for (var i = FilteredPosts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!visiblePosts.Contains(FilteredPosts[i]))
|
||||
FilteredPosts.RemoveAt(i);
|
||||
}
|
||||
for (var i = 0; i < visiblePosts.Length; i++)
|
||||
{
|
||||
var previousIndex = FilteredPosts.IndexOf(visiblePosts[i]);
|
||||
if (previousIndex < 0)
|
||||
FilteredPosts.Insert(i, visiblePosts[i]);
|
||||
else if (previousIndex != i)
|
||||
FilteredPosts.Move(previousIndex, i);
|
||||
}
|
||||
SelectedPost = selection;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_preserveDraft = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -594,40 +656,53 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
}
|
||||
}
|
||||
|
||||
private bool TryAppendAttachmentLinks(BlogPostDto post, IReadOnlyCollection<BlogUploadFile> attachments)
|
||||
private void DraftAttachments_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (attachments.Count == 0)
|
||||
return false;
|
||||
|
||||
var ownerSegment = post.Author?.UserName;
|
||||
if (string.IsNullOrWhiteSpace(ownerSegment))
|
||||
ownerSegment = post.AuthorId;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ownerSegment))
|
||||
return false;
|
||||
|
||||
var article = DraftArticle ?? string.Empty;
|
||||
var links = new List<string>();
|
||||
|
||||
foreach (var attachment in attachments)
|
||||
if (e.NewItems is not null)
|
||||
{
|
||||
var relativePath = $"{EscapePathSegment(ownerSegment)}/blogs/{post.Id}/{EscapePathSegment(attachment.FileName)}";
|
||||
var fileUrl = ResolveUserFileUrl(relativePath);
|
||||
var markdownLine = $"- [{attachment.FileName}]({fileUrl})";
|
||||
|
||||
if (!article.Contains(markdownLine, StringComparison.Ordinal))
|
||||
links.Add(markdownLine);
|
||||
foreach (BlogUploadFile attachment in e.NewItems)
|
||||
{
|
||||
var storedName = AbstractFileSystemHelpers.FilterFileName(attachment.FileName);
|
||||
string fileUrl;
|
||||
if (SelectedPost is { Id: > 0 } post)
|
||||
fileUrl = AttachmentUrl(post, storedName);
|
||||
else
|
||||
{
|
||||
fileUrl = $"postit-attachment:{Guid.NewGuid():N}";
|
||||
_pendingAttachmentLinks.Add(fileUrl, storedName);
|
||||
}
|
||||
var article = DraftArticle ?? string.Empty;
|
||||
var markdownLine = $"- [{attachment.FileName}]({fileUrl})";
|
||||
if (!article.Contains(markdownLine, StringComparison.Ordinal))
|
||||
{
|
||||
var prefix = article.Length == 0 ? ""
|
||||
: (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n");
|
||||
DraftArticle = article + prefix + markdownLine;
|
||||
}
|
||||
}
|
||||
}
|
||||
UpdateDraftModified();
|
||||
}
|
||||
|
||||
if (links.Count == 0)
|
||||
return false;
|
||||
private string AttachmentUrl(BlogPostDto post, string storedName)
|
||||
{
|
||||
var owner = post.Author?.UserName ?? post.AuthorId;
|
||||
if (string.IsNullOrWhiteSpace(owner))
|
||||
throw new InvalidOperationException("Le propriétaire du billet est nécessaire au lien du fichier.");
|
||||
return ResolveUserFileUrl(
|
||||
$"{EscapePathSegment(owner)}/blogs/{post.Id}/{EscapePathSegment(storedName)}");
|
||||
}
|
||||
|
||||
var prefix = article.Length == 0
|
||||
? ""
|
||||
: (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n");
|
||||
|
||||
DraftArticle = article + prefix + string.Join("\n", links);
|
||||
return true;
|
||||
private bool ResolvePendingAttachmentLinks(BlogPostDto post)
|
||||
{
|
||||
var article = DraftArticle ?? string.Empty;
|
||||
foreach (var link in _pendingAttachmentLinks)
|
||||
article = article.Replace($"({link.Key})", $"({AttachmentUrl(post, link.Value)})",
|
||||
StringComparison.Ordinal);
|
||||
var changed = article != DraftArticle;
|
||||
if (changed) DraftArticle = article;
|
||||
_pendingAttachmentLinks.Clear();
|
||||
return changed;
|
||||
}
|
||||
|
||||
private string ResolveUserFileUrl(string relativePath)
|
||||
|
|
@ -636,7 +711,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
if (!string.IsNullOrWhiteSpace(authority)
|
||||
&& Uri.TryCreate(authority, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).ToString();
|
||||
return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).AbsoluteUri;
|
||||
}
|
||||
|
||||
return $"{Yavsc.Constants.UserFilesPath}/{relativePath}";
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels.Chat;
|
||||
using Yavsc.Abstract.Models.Messaging;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
public class HomePageViewModel : ViewModelBase
|
||||
|
|
@ -13,6 +18,26 @@ public class HomePageViewModel : ViewModelBase
|
|||
public YavscApiClient Api { get; }
|
||||
public Settings Settings { get; }
|
||||
public SessionStatusViewModel SessionStatus { get; }
|
||||
public ObservableCollection<Notification> Notifications { get; } = new();
|
||||
private readonly NotificationsApiClient? _notificationsClient;
|
||||
private int _notificationsLoadVersion;
|
||||
private bool _isLoadingNotifications;
|
||||
private string? _notificationsError;
|
||||
private bool _notificationsLoaded;
|
||||
|
||||
public bool IsLoadingNotifications
|
||||
{
|
||||
get => _isLoadingNotifications;
|
||||
private set => SetProperty(ref _isLoadingNotifications, value);
|
||||
}
|
||||
|
||||
public string? NotificationsError
|
||||
{
|
||||
get => _notificationsError;
|
||||
private set => SetProperty(ref _notificationsError, value);
|
||||
}
|
||||
|
||||
public bool HasNoNotifications => _notificationsLoaded && Notifications.Count == 0;
|
||||
|
||||
private string _welcomeText = "Welcome to PostIt!";
|
||||
public string WelcomeText
|
||||
|
|
@ -24,11 +49,16 @@ public class HomePageViewModel : ViewModelBase
|
|||
public override bool CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); }
|
||||
public override bool CanNavigatePrevious { get => false; protected set => throw new System.NotImplementedException(); }
|
||||
|
||||
public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus)
|
||||
public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus,
|
||||
NotificationsApiClient notificationsClient)
|
||||
{
|
||||
Api = api;
|
||||
Settings = settings;
|
||||
SessionStatus = sessionStatus;
|
||||
_notificationsClient = notificationsClient;
|
||||
SessionStatus.PropertyChanged += OnSessionStatusChanged;
|
||||
RefreshNotificationsCommand = new AsyncRelayCommand(RefreshNotificationsAsync,
|
||||
() => _notificationsClient is not null);
|
||||
|
||||
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
|
||||
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
|
||||
|
|
@ -55,6 +85,59 @@ public class HomePageViewModel : ViewModelBase
|
|||
public IAsyncRelayCommand OpenClientEstimates { get; }
|
||||
public IAsyncRelayCommand OpenProviderEstimates { get; }
|
||||
public IAsyncRelayCommand OpenMyFiles { get; }
|
||||
public IAsyncRelayCommand RefreshNotificationsCommand { get; }
|
||||
|
||||
public async Task RefreshNotificationsAsync()
|
||||
{
|
||||
if (_notificationsClient is null)
|
||||
throw new InvalidOperationException("Notifications client is not available.");
|
||||
|
||||
var version = ++_notificationsLoadVersion;
|
||||
Notifications.Clear();
|
||||
NotificationsError = null;
|
||||
_notificationsLoaded = false;
|
||||
OnPropertyChanged(nameof(HasNoNotifications));
|
||||
IsLoadingNotifications = true;
|
||||
try
|
||||
{
|
||||
var notifications = SessionStatus.IsLoggedIn
|
||||
? await _notificationsClient.GetCurrentAsync()
|
||||
: await _notificationsClient.GetPublicAsync();
|
||||
if (version != _notificationsLoadVersion)
|
||||
return;
|
||||
if (notifications is null)
|
||||
throw new InvalidOperationException("The notifications response is empty.");
|
||||
|
||||
foreach (var notification in notifications)
|
||||
Notifications.Add(notification);
|
||||
_notificationsLoaded = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (version == _notificationsLoadVersion)
|
||||
NotificationsError = $"Impossible de charger les notifications : {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (version == _notificationsLoadVersion)
|
||||
{
|
||||
IsLoadingNotifications = false;
|
||||
OnPropertyChanged(nameof(HasNoNotifications));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSessionStatusChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName != nameof(SessionStatusViewModel.IsLoggedIn) || _notificationsClient is null)
|
||||
return;
|
||||
|
||||
// Logout can complete on a worker thread; collections and bindings belong to the UI thread.
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
_ = RefreshNotificationsAsync();
|
||||
else
|
||||
Dispatcher.UIThread.Post(() => _ = RefreshNotificationsAsync());
|
||||
}
|
||||
|
||||
private async Task OpenActivitiesAsync()
|
||||
{
|
||||
|
|
@ -228,7 +311,7 @@ public class HomePageViewModel : ViewModelBase
|
|||
/// (thread-safe dispatcher marshalling on PropertyChanged) — a
|
||||
/// designer-only duplicate instance is therefore harmless.
|
||||
/// </summary>
|
||||
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel())
|
||||
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel(), null!)
|
||||
{
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@
|
|||
<Design.DataContext>
|
||||
<vm:BlogsViewModel />
|
||||
</Design.DataContext>
|
||||
<ContentPage.Styles>
|
||||
<Style Selector="CommandBarButton.modified">
|
||||
<Setter Property="Background" Value="#FFF0C2" />
|
||||
<Setter Property="Foreground" Value="#663C00" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
</Style>
|
||||
</ContentPage.Styles>
|
||||
<Grid
|
||||
RowDefinitions="Auto,Auto,*"
|
||||
Margin="5,5,10,5"
|
||||
|
|
@ -46,7 +53,9 @@
|
|||
</CommandBarButton>
|
||||
<CommandBarButton
|
||||
x:Name="SaveButton"
|
||||
Command="{Binding SaveAsync}"
|
||||
Command="{Binding SaveCommand}"
|
||||
Classes.modified="{Binding IsDraftModified}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Label="Save">
|
||||
<CommandBarButton.Icon>
|
||||
<PathIcon Data="{StaticResource SaveIcon}" />
|
||||
|
|
@ -60,6 +69,7 @@
|
|||
<CommandBarButton
|
||||
x:Name="AddAttachmentButton"
|
||||
Click="AddAttachment_Click"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Label="Ajouter fichiers">
|
||||
<CommandBarButton.Icon>
|
||||
<PathIcon Data="{StaticResource AddIcon}" />
|
||||
|
|
@ -118,6 +128,7 @@
|
|||
BorderThickness="1">
|
||||
<ListBox
|
||||
x:Name="PostsListBox"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:BlogPostDto">
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ public partial class BlogsPage : ContentPage
|
|||
uploads.Add(new Yavsc.Api.Client.BlogUploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name)));
|
||||
}
|
||||
|
||||
vm.DraftAttachments.Clear();
|
||||
foreach (var upload in uploads)
|
||||
vm.DraftAttachments.Add(upload);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,50 @@
|
|||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PostIt.ViewModels"
|
||||
xmlns:lang="using:Yavsc.Lang"
|
||||
xmlns:messaging="using:Yavsc.Abstract.Models.Messaging"
|
||||
x:Class="PostIt.Views.HomePage"
|
||||
x:DataType="vm:HomePageViewModel"
|
||||
Header="Home">
|
||||
<Design.DataContext>
|
||||
<vm:HomePageViewModel />
|
||||
</Design.DataContext>
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="12">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="16" Spacing="12" MaxWidth="720"
|
||||
HorizontalAlignment="Stretch">
|
||||
<TextBlock Text="{x:Static lang:Resources.GreetingText}"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"/>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="Notifications" FontSize="20" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Actualiser"
|
||||
Command="{Binding RefreshNotificationsCommand}"
|
||||
IsEnabled="{Binding !IsLoadingNotifications}"/>
|
||||
</Grid>
|
||||
<ProgressBar IsIndeterminate="True" Height="4"
|
||||
IsVisible="{Binding IsLoadingNotifications}"/>
|
||||
<TextBlock Text="{Binding NotificationsError}" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SystemControlErrorTextForegroundBrush}"/>
|
||||
<TextBlock Text="Aucune notification pour le moment."
|
||||
IsVisible="{Binding HasNoNotifications}" TextWrapping="Wrap"/>
|
||||
<ItemsControl x:Name="NotificationsList" ItemsSource="{Binding Notifications}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="messaging:Notification">
|
||||
<Border Padding="12" Margin="0,0,0,8" CornerRadius="6"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="{Binding title}" FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding body}" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Button Content="Les Salons de discussion"
|
||||
Command="{Binding OpenChat}"
|
||||
HorizontalAlignment="Center"
|
||||
|
|
@ -61,4 +91,5 @@
|
|||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Avalonia.Controls;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Views;
|
||||
|
||||
|
|
@ -8,4 +9,12 @@ public partial class HomePage : ContentPage
|
|||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnNavigatedTo(NavigatedToEventArgs e)
|
||||
{
|
||||
base.OnNavigatedTo(e);
|
||||
if (!Design.IsDesignMode && DataContext is HomePageViewModel vm
|
||||
&& vm.RefreshNotificationsCommand.CanExecute(null))
|
||||
_ = vm.RefreshNotificationsAsync();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ namespace Yavsc
|
|||
public const string ResourceUsagePath = "resource-usage";
|
||||
public const string ResourceUsageCurrentUserRoute = "me";
|
||||
public const string ResourceUsageAdminRoute = "admin";
|
||||
public const string NotificationsPath = "notifications";
|
||||
public const string NotificationsCurrentUserRoute = "me";
|
||||
public const string ResourceUsageDefaultCurrency = "EUR";
|
||||
public const string ResourceUsageCurrentUserDisplayName = "current-user";
|
||||
public const string ResourceUsageAdminDisplayName = "administrator";
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ namespace Yavsc.Server.Helpers
|
|||
// Ensure this path is canonical,
|
||||
// No "dirto/./this", neither "dirt/to/that/"
|
||||
// no .. and each char must be listed as valid in constants
|
||||
// Encode unsupported chars with '_' rather than the URL fragment marker '#'.
|
||||
public static string FilterFileName(string fileName)
|
||||
{
|
||||
if (fileName==null) return null;
|
||||
|
|
@ -57,7 +58,7 @@ namespace Yavsc.Server.Helpers
|
|||
{
|
||||
if (ValidFileNameChars.Contains(c))
|
||||
sb.Append(c);
|
||||
else sb.Append("#" + ((int)c).ToString("D3"));
|
||||
else sb.Append("_" + ((int)c).ToString("D3"));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ public interface IYavscApiClient : IAsyncDisposable
|
|||
/// </summary>
|
||||
HttpClient Http { get; }
|
||||
|
||||
/// <summary>Read a public JSON endpoint without attaching session credentials.</summary>
|
||||
Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException("Anonymous requests are not supported by this transport.");
|
||||
|
||||
/// <summary>Call a JSON endpoint with a typed return value.</summary>
|
||||
/// <param name="method">HTTP verb.</param>
|
||||
/// <param name="path">Path relative to <see cref="HttpClient.BaseAddress"/>.</param>
|
||||
|
|
|
|||
61
src/Yavsc.Api.Client/NotificationsApiClient.cs
Normal file
61
src/Yavsc.Api.Client/NotificationsApiClient.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Yavsc.Abstract.Models.Messaging;
|
||||
|
||||
namespace Yavsc.Api.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Thin HTTP client for the notifications exposed to PostIt on the home page.
|
||||
/// </summary>
|
||||
public sealed class NotificationsApiClient
|
||||
{
|
||||
private const string PathPrefix = Constants.NotificationsPath;
|
||||
|
||||
private readonly IYavscApiClient _api;
|
||||
private readonly Func<string> _businessBaseAddress;
|
||||
|
||||
public NotificationsApiClient(IYavscApiClient api, string businessBaseAddress)
|
||||
: this(api, () => businessBaseAddress)
|
||||
{
|
||||
}
|
||||
|
||||
public NotificationsApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
|
||||
{
|
||||
_api = api ?? throw new ArgumentNullException(nameof(api));
|
||||
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
|
||||
_ = ResolveBusinessBaseAddress();
|
||||
}
|
||||
|
||||
public Task<List<Notification>> GetCurrentAsync(CancellationToken ct = default)
|
||||
{
|
||||
return _api.CallAsync<List<Notification>>(
|
||||
HttpMethod.Get,
|
||||
Absolute($"{PathPrefix}/{Constants.NotificationsCurrentUserRoute}"),
|
||||
ct: ct);
|
||||
}
|
||||
|
||||
public Task<List<Notification>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
return _api.CallAsync<List<Notification>>(
|
||||
HttpMethod.Get,
|
||||
Absolute(PathPrefix),
|
||||
ct: ct);
|
||||
}
|
||||
|
||||
public Task<List<Notification>> GetPublicAsync(CancellationToken ct = default)
|
||||
=> _api.GetAnonymousAsync<List<Notification>>(Absolute(PathPrefix), ct);
|
||||
|
||||
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
||||
|
||||
private Uri ResolveBusinessBaseAddress()
|
||||
{
|
||||
var raw = _businessBaseAddress();
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
throw new InvalidOperationException("Business base address is required.");
|
||||
|
||||
return new Uri(raw, UriKind.Absolute);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Yavsc.Abstract.Models.Messaging;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Server.Helpers;
|
||||
|
||||
namespace Yavsc.ApiControllers.Administration;
|
||||
|
||||
[Produces("application/json")]
|
||||
[Route(Constants.APIPrefix + "/" + Constants.NotificationsPath)]
|
||||
public sealed class NotificationsApiController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _dbContext;
|
||||
|
||||
public NotificationsApiController(ApplicationDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[HttpGet(Constants.NotificationsCurrentUserRoute)]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<List<Notification>>> GetCurrentUserNotifications()
|
||||
{
|
||||
var currentUserId = User.GetUserId();
|
||||
var isAdmin = User.IsInRole(Constants.AdminGroupName);
|
||||
|
||||
var dismissedIds = string.IsNullOrWhiteSpace(currentUserId)
|
||||
? new HashSet<long>()
|
||||
: await _dbContext.DismissClicked
|
||||
.AsNoTracking()
|
||||
.Where(d => d.UserId == currentUserId)
|
||||
.Select(d => d.NotificationId)
|
||||
.ToHashSetAsync();
|
||||
|
||||
var notifications = await _dbContext.Notification
|
||||
.AsNoTracking()
|
||||
.Where(n => !dismissedIds.Contains(n.Id))
|
||||
.Where(n =>
|
||||
string.IsNullOrWhiteSpace(n.Target)
|
||||
|| (n.Target != null && n.Target.StartsWith("pub/"))
|
||||
|| (!string.IsNullOrWhiteSpace(currentUserId) && n.Target == $"user/{currentUserId}")
|
||||
|| (isAdmin && n.Target == "administration"))
|
||||
.OrderByDescending(n => n.Id)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(notifications);
|
||||
}
|
||||
}
|
||||
|
|
@ -322,6 +322,8 @@ namespace Yavsc.ApiControllers
|
|||
await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"cli", siteSettings);
|
||||
estimate.ClientValidationDate = DateTime.UtcNow;
|
||||
dbContext.SaveChanges(User.GetUserId());
|
||||
var signedEvent = new ClientEstimateSignedEvent(estimate, estimate.Client.UserName);
|
||||
await _GCMSender.NotifyAsync(new[] { signedEvent.PerformerId }, signedEvent);
|
||||
return Ok (new { ClientValidationDate = estimate.ClientValidationDate });
|
||||
}
|
||||
|
||||
|
|
@ -382,12 +384,9 @@ namespace Yavsc.ApiControllers
|
|||
.FirstOrDefaultAsync(e => e.Id == id, token);
|
||||
if (estimate is null) return NotFound(new { Error = "estimate not found" });
|
||||
|
||||
// The signer is identified by userId in the body, not
|
||||
// by the bearer token, because the OAuth scope we
|
||||
// carry is for the API client (PostIt), not the end
|
||||
// user. We trust the body's userId to match either
|
||||
// Owner or Client, and reject everything else.
|
||||
var userId = body.SignerUserId;
|
||||
if (userId != User.GetUserId())
|
||||
return Forbid();
|
||||
if (userId != estimate.OwnerId && userId != estimate.ClientId)
|
||||
return Forbid();
|
||||
|
||||
|
|
@ -414,6 +413,9 @@ namespace Yavsc.ApiControllers
|
|||
var signature = await EstimateSignaturePersister.StageAsync(
|
||||
dbContext, id, type, userId, payload, token);
|
||||
|
||||
if (type == SignatureType.Client)
|
||||
estimate.ClientValidationDate = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(token);
|
||||
|
|
@ -424,6 +426,12 @@ namespace Yavsc.ApiControllers
|
|||
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
|
||||
}
|
||||
|
||||
if (type == SignatureType.Client)
|
||||
{
|
||||
var signedEvent = new ClientEstimateSignedEvent(estimate, estimate.Client.UserName);
|
||||
await _GCMSender.NotifyAsync(new[] { signedEvent.PerformerId }, signedEvent);
|
||||
}
|
||||
|
||||
var location = Url.Action(nameof(Sign), new { id })
|
||||
?? $"/api/bill/estimate/{id}/sign";
|
||||
return Created(location, new
|
||||
|
|
@ -441,9 +449,8 @@ namespace Yavsc.ApiControllers
|
|||
/// <summary>
|
||||
/// JSON body of <c>POST /api/bill/estimate/{id}/sign</c>. The
|
||||
/// shape mirrors what PostIt sends; the <c>signerUserId</c>
|
||||
/// field disambiguates which side of the estimate signed
|
||||
/// because the bearer token belongs to the PostIt OAuth
|
||||
/// client, not the end user.
|
||||
/// field must match the authenticated caller and identifies
|
||||
/// which party of the estimate signed.
|
||||
/// </summary>
|
||||
public class SignatureSubmission
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
#nullable enable annotations
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
|
@ -10,6 +12,7 @@ using Yavsc.Helpers;
|
|||
using Yavsc.Models;
|
||||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Models.Messaging;
|
||||
using Yavsc.Services;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.ViewModels.FrontOffice;
|
||||
|
|
@ -26,6 +29,7 @@ namespace Yavsc.ApiControllers
|
|||
private readonly ILogger logger;
|
||||
private readonly SiteSettings siteSettings;
|
||||
private readonly IRazorViewEngine viewEngine;
|
||||
private readonly IYavscMessageSender messageSender;
|
||||
|
||||
// The view-rendering dependencies (siteSettings, viewEngine) are
|
||||
// optional: only EstimateTex/EstimatePdf use them, and those run in
|
||||
|
|
@ -45,11 +49,13 @@ namespace Yavsc.ApiControllers
|
|||
public FrontOfficeApiController(
|
||||
ApplicationDbContext context,
|
||||
ILoggerFactory loggerFactory,
|
||||
IYavscMessageSender messageSender,
|
||||
IOptions<SiteSettings> siteSettings = null,
|
||||
IRazorViewEngine viewEngine = null,
|
||||
IBillingService billing = null)
|
||||
{
|
||||
dbContext = context;
|
||||
this.messageSender = messageSender;
|
||||
this.billing = billing ?? new BillingService(context);
|
||||
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
|
||||
this.siteSettings = siteSettings?.Value;
|
||||
|
|
@ -233,6 +239,7 @@ namespace Yavsc.ApiControllers
|
|||
if (query is null) return BadRequest(new { Error = "query not found" });
|
||||
|
||||
Signature? signature = null;
|
||||
ClientEstimateSignedEvent? signedEvent = null;
|
||||
// actingRole is the role the caller is signing / accepting
|
||||
// as. It defaults to the role inferred from the caller's
|
||||
// identity, but when a signature is submitted the caller
|
||||
|
|
@ -299,7 +306,11 @@ namespace Yavsc.ApiControllers
|
|||
if (actingRole == ActorRole.Provider)
|
||||
estimate.ProviderValidationDate = DateTime.UtcNow;
|
||||
else
|
||||
{
|
||||
estimate.ClientValidationDate = DateTime.UtcNow;
|
||||
var signer = await dbContext.Users.SingleAsync(u => u.Id == uid, token);
|
||||
signedEvent = new ClientEstimateSignedEvent(estimate, signer.UserName);
|
||||
}
|
||||
}
|
||||
|
||||
// The party-specific status records who accepted: the
|
||||
|
|
@ -323,6 +334,9 @@ namespace Yavsc.ApiControllers
|
|||
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
|
||||
}
|
||||
|
||||
if (signedEvent is not null)
|
||||
await messageSender.NotifyAsync(new[] { signedEvent.PerformerId }, signedEvent);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
queryId,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ using Yavsc.Models.Billing;
|
|||
using Yavsc.Models.Relationship;
|
||||
using Yavsc.Models.Workflow;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.Helpers;
|
||||
using Yavsc.Services;
|
||||
|
||||
namespace Yavsc.Controllers;
|
||||
|
||||
|
|
@ -19,10 +21,12 @@ namespace Yavsc.Controllers;
|
|||
public class RdvQueryApiController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IYavscMessageSender _messageSender;
|
||||
|
||||
public RdvQueryApiController(ApplicationDbContext context)
|
||||
public RdvQueryApiController(ApplicationDbContext context, IYavscMessageSender messageSender)
|
||||
{
|
||||
_context = context;
|
||||
_messageSender = messageSender;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
|
|
@ -93,6 +97,16 @@ public class RdvQueryApiController : Controller
|
|||
return BadRequest(new { Error = "location is required" });
|
||||
}
|
||||
|
||||
var performer = await _context.Performers
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(p => p.PerformerId == query.PerformerId, cancellationToken);
|
||||
if (performer is null)
|
||||
return BadRequest(new { Error = "performer not found" });
|
||||
var client = await _context.Users.SingleOrDefaultAsync(u => u.Id == uid, cancellationToken);
|
||||
if (client is null)
|
||||
return Unauthorized();
|
||||
query.Client = client;
|
||||
|
||||
var resolvedLocation = await ResolveLocationAsync(query.Location, cancellationToken);
|
||||
if (resolvedLocation is null)
|
||||
{
|
||||
|
|
@ -124,6 +138,9 @@ public class RdvQueryApiController : Controller
|
|||
throw;
|
||||
}
|
||||
|
||||
if (performer.AcceptNotifications && performer.AcceptPublicContact)
|
||||
await _messageSender.NotifyBookQueryAsync(new[] { performer.PerformerId }, query.CreateEvent("NewCommand"));
|
||||
|
||||
return CreatedAtRoute("GetRdvQuery", new { id = query.Id }, query);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -134,11 +134,11 @@ internal class Program
|
|||
if (app.Environment.IsDevelopment())
|
||||
app.UseDeveloperExceptionPage();
|
||||
|
||||
app.UseMiddleware<ResourceUsageTelemetryMiddleware>();
|
||||
app
|
||||
.UseRouting()
|
||||
.UseAuthentication()
|
||||
.UseAuthorization()
|
||||
.UseMiddleware<ResourceUsageTelemetryMiddleware>()
|
||||
.UseCors("default");
|
||||
app.MapIdentityApi<ApplicationUser>().RequireAuthorization("ApiScope");
|
||||
app.MapDefaultControllerRoute();
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
namespace Yavsc.Blogs;
|
||||
|
||||
public static class BlogConstants
|
||||
{
|
||||
public const string AdminRole = "Admin";
|
||||
public const string ModeratorRole = "Moderator";
|
||||
public const string UserRole = "User";
|
||||
}
|
||||
|
|
@ -200,7 +200,7 @@ namespace Yavsc.Blogs.Controllers
|
|||
if (string.IsNullOrEmpty(ownerUserName))
|
||||
return NotFound(new { error = "owner not found" });
|
||||
|
||||
var physicalPath = Path.Combine(siteSettings.Blog, ownerUserName, file.Path ?? "");
|
||||
var physicalPath = Path.Combine(siteSettings.UserFilesRoot, ownerUserName, file.Path ?? "");
|
||||
var fi = new FileInfo(physicalPath);
|
||||
if (!fi.Exists) return NotFound(new { error = "file not on disk" });
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ using Yavsc.Models;
|
|||
using Yavsc.Services;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
namespace Yavsc.Blogs;
|
||||
|
||||
internal class Program
|
||||
|
|
@ -85,9 +84,9 @@ internal class Program
|
|||
})
|
||||
.Configure<SiteSettings>(settings =>
|
||||
{
|
||||
if (settings.Blog == null)
|
||||
if (settings.UserFilesRoot == null)
|
||||
{
|
||||
settings.Blog = "blog";
|
||||
settings.UserFilesRoot = "blog";
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -98,11 +97,11 @@ internal class Program
|
|||
{
|
||||
if (app.Environment.IsDevelopment())
|
||||
app.UseDeveloperExceptionPage();
|
||||
app.UseMiddleware<ResourceUsageTelemetryMiddleware>();
|
||||
app
|
||||
.UseRouting()
|
||||
.UseAuthentication()
|
||||
.UseAuthorization()
|
||||
.UseMiddleware<ResourceUsageTelemetryMiddleware>()
|
||||
.UseCors("default")
|
||||
;
|
||||
app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Program")
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"https://localhost:5005",
|
||||
"https://api.pschneider.fr"
|
||||
],
|
||||
"Blog": "blog"
|
||||
"UserFilesRoot": "files"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
||||
|
|
|
|||
|
|
@ -421,9 +421,9 @@ namespace Yavsc.Controllers
|
|||
{
|
||||
// Renames the blog files
|
||||
var userdirinfo = new DirectoryInfo(
|
||||
Path.Combine(_siteSettings.Blog,
|
||||
Path.Combine(_siteSettings.UserFilesRoot,
|
||||
oldUserName));
|
||||
var newdir = Path.Combine(_siteSettings.Blog,
|
||||
var newdir = Path.Combine(_siteSettings.UserFilesRoot,
|
||||
model.UserName);
|
||||
if (userdirinfo.Exists)
|
||||
userdirinfo.MoveTo(newdir);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Identity;
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Yavsc.Abstract.Identity;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Server.Helpers;
|
||||
|
|
@ -19,14 +20,20 @@ namespace Yavsc.Controllers
|
|||
private readonly RoleManager<IdentityRole> _roleManager;
|
||||
|
||||
private readonly ApplicationDbContext _dbContext;
|
||||
private readonly SiteSettings _siteSettings;
|
||||
private readonly ILogger<AdministrationController> _logger;
|
||||
|
||||
public AdministrationController(UserManager<ApplicationUser> userManager,
|
||||
RoleManager<IdentityRole> roleManager,
|
||||
ApplicationDbContext context)
|
||||
ApplicationDbContext context,
|
||||
IOptions<SiteSettings> siteSettings,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_roleManager = roleManager;
|
||||
this._dbContext = context;
|
||||
this._siteSettings = siteSettings.Value;
|
||||
this._logger = loggerFactory.CreateLogger<AdministrationController>();
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -92,6 +99,16 @@ namespace Yavsc.Controllers
|
|||
}
|
||||
|
||||
var user = await _userManager.FindByIdAsync(User.GetUserId());
|
||||
|
||||
var email = user.Email;
|
||||
var isOk = email == _siteSettings.Owner?.EMail || _siteSettings.Admin?.EMail == email;
|
||||
if (!isOk)
|
||||
{
|
||||
if (_siteSettings.Owner==null && _siteSettings.Admin==null)
|
||||
_logger.LogError("No owner or admin is set in the site settings.");
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
// check all user groups exist
|
||||
if (!await EnsureRoleList())
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1046,7 +1046,6 @@ public static class HostingExtensions
|
|||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
app.UseStaticFiles();
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseIdentityServer();
|
||||
|
|
@ -1267,7 +1266,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
|
|||
{
|
||||
throw new InvalidOperationException("GitRepository path is not configured in SiteSettings.");
|
||||
}
|
||||
if (settings.Blog == null)
|
||||
if (settings.UserFilesRoot == null)
|
||||
{
|
||||
throw new InvalidOperationException("Blog path is not configured in SiteSettings.");
|
||||
}
|
||||
|
|
@ -1284,7 +1283,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
|
|||
throw new InvalidOperationException("TempDir path is not configured in SiteSettings.");
|
||||
}
|
||||
|
||||
var userFilesDirInfo = new DirectoryInfo(settings.Blog);
|
||||
var userFilesDirInfo = new DirectoryInfo(settings.UserFilesRoot);
|
||||
|
||||
if (!userFilesDirInfo.Exists) userFilesDirInfo.Create();
|
||||
|
||||
|
|
|
|||
|
|
@ -11,28 +11,6 @@ namespace Yavsc.Helpers
|
|||
|
||||
public static class EventHelpers
|
||||
{
|
||||
public static RdvQueryEvent CreateEvent(this RdvQuery query, string subtopic)
|
||||
{
|
||||
var yaev = new RdvQueryEvent(subtopic)
|
||||
{
|
||||
Sender = query.ClientId,
|
||||
Reason = query.Reason,
|
||||
Client = new ClientProviderInfo {
|
||||
UserName = query.Client.UserName ,
|
||||
UserId = query.ClientId,
|
||||
Avatar = query.Client.Avatar } ,
|
||||
Previsional = query.Provisional,
|
||||
EventDate = query.EventDate,
|
||||
Location = query.Location,
|
||||
Id = query.Id,
|
||||
ActivityCode = query.ActivityCode,
|
||||
BillingCode = BillingCodes.Rdv
|
||||
};
|
||||
|
||||
return yaev;
|
||||
}
|
||||
|
||||
|
||||
public static HairCutQueryEvent CreateNewHairCutQueryEvent(this HairCutQuery query,
|
||||
IStringLocalizer<HairCutQuery> SR)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ public class Startup
|
|||
ResourcesHelpers.GlobalLocalizer = localizer;
|
||||
Config.SmtpSetup = smtpSettings.Value;
|
||||
Config.Authority = siteSettings.Value.Authority;
|
||||
string blogsDir = siteSettings.Value.Blog ?? throw new Exception("blogsDir is not set.");
|
||||
string blogsDir = siteSettings.Value.UserFilesRoot ?? throw new Exception("blogsDir is not set.");
|
||||
string billsDir = siteSettings.Value.Bills ?? throw new Exception("billsDir is not set.");
|
||||
string tempDir = siteSettings.Value.TempDir ?? throw new Exception("tempDir is not set.");
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ namespace Yavsc.ViewComponents
|
|||
{
|
||||
string uid = ViewContext.HttpContext.User.GetUserId();
|
||||
return View(new UserDirectoryInfo(
|
||||
siteSettings.Blog,
|
||||
siteSettings.UserFilesRoot,
|
||||
uid, dirname));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
"Avatars": "avatars",
|
||||
"Quota": 200000000,
|
||||
"Bills": "bills",
|
||||
"Blog": "blog",
|
||||
"UserFilesRoot": "files",
|
||||
"TempDir": "temp",
|
||||
"DataDir": "data",
|
||||
"GitRepository": "git"
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ namespace Yavsc.Server.Helpers
|
|||
this ClaimsPrincipal user,
|
||||
string subpath, SiteSettings siteSettings)
|
||||
{
|
||||
var root = Path.Combine(siteSettings.Blog, user.GetUserName());
|
||||
var root = Path.Combine(siteSettings.UserFilesRoot, user.GetUserName());
|
||||
if (!string.IsNullOrWhiteSpace(subpath))
|
||||
{
|
||||
if (!subpath.IsValidYavscPath())
|
||||
|
|
@ -67,7 +67,7 @@ namespace Yavsc.Server.Helpers
|
|||
|
||||
public static void DeleteUserFile(this ApplicationUser user, string fileName, SiteSettings siteSettings)
|
||||
{
|
||||
var root = Path.Combine(siteSettings.Blog, user.UserName);
|
||||
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
|
||||
var fi = new FileInfo(Path.Combine(root, fileName));
|
||||
if (!fi.Exists) return;
|
||||
// Capture the length BEFORE deleting: FileInfo.Length throws
|
||||
|
|
@ -80,7 +80,7 @@ namespace Yavsc.Server.Helpers
|
|||
|
||||
public static FsOperationInfo DeleteUserDirOrFile(this ApplicationUser user, string dirName, SiteSettings siteSettings)
|
||||
{
|
||||
var root = Path.Combine(siteSettings.Blog, user.UserName);
|
||||
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
|
||||
if (string.IsNullOrEmpty(dirName))
|
||||
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "specify a directory or file name" };
|
||||
|
||||
|
|
@ -110,7 +110,7 @@ namespace Yavsc.Server.Helpers
|
|||
|
||||
public static FsOperationInfo MoveUserDir(this ApplicationUser user, string fromDirName, string toDirName, SiteSettings siteSettings)
|
||||
{
|
||||
var root = Path.Combine(siteSettings.Blog, user.UserName);
|
||||
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
|
||||
if (string.IsNullOrEmpty(fromDirName))
|
||||
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "specify a dir name " };
|
||||
|
||||
|
|
@ -136,7 +136,7 @@ namespace Yavsc.Server.Helpers
|
|||
}
|
||||
public static FsOperationInfo MoveUserFileToDir(this ApplicationUser user, string fileNameFrom, string fileNameDest, SiteSettings siteSettings)
|
||||
{
|
||||
var root = Path.Combine(siteSettings.Blog, user.UserName);
|
||||
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
|
||||
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
|
||||
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
|
||||
string dest;
|
||||
|
|
@ -151,7 +151,7 @@ namespace Yavsc.Server.Helpers
|
|||
}
|
||||
public static FsOperationInfo MoveUserFile(this ApplicationUser user, string fileNameFrom, string fileNameDest, SiteSettings siteSettings)
|
||||
{
|
||||
var root = Path.Combine(siteSettings.Blog, user.UserName);
|
||||
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
|
||||
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
|
||||
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
|
||||
var fo = new FileInfo(Path.Combine(root, fileNameDest));
|
||||
|
|
@ -318,7 +318,7 @@ namespace Yavsc.Server.Helpers
|
|||
|
||||
public static UserDirectoryInfo GetUserFiles(SiteSettings settings, string userId, string subdir)
|
||||
{
|
||||
UserDirectoryInfo di = new UserDirectoryInfo(settings.Blog, userId, subdir);
|
||||
UserDirectoryInfo di = new UserDirectoryInfo(settings.UserFilesRoot, userId, subdir);
|
||||
return di;
|
||||
}
|
||||
|
||||
|
|
|
|||
29
src/Yavsc.Server/Helpers/RdvQueryEventHelpers.cs
Normal file
29
src/Yavsc.Server/Helpers/RdvQueryEventHelpers.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using Yavsc.Abstract.Identity;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Models.Messaging;
|
||||
using Yavsc.Models.Workflow;
|
||||
|
||||
namespace Yavsc.Helpers;
|
||||
|
||||
public static class RdvQueryEventHelpers
|
||||
{
|
||||
public static RdvQueryEvent CreateEvent(this RdvQuery query, string subtopic)
|
||||
=> new(subtopic)
|
||||
{
|
||||
Sender = query.ClientId,
|
||||
Reason = query.Reason,
|
||||
Client = new ClientProviderInfo
|
||||
{
|
||||
UserName = query.Client.UserName,
|
||||
UserId = query.ClientId,
|
||||
Avatar = query.Client.Avatar
|
||||
},
|
||||
Previsional = query.Provisional,
|
||||
EventDate = query.EventDate,
|
||||
Location = query.Location,
|
||||
Id = query.Id,
|
||||
ActivityCode = query.ActivityCode,
|
||||
BillingCode = BillingCodes.Rdv
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
using Yavsc.Interfaces.Workflow;
|
||||
using Yavsc.Models.Billing;
|
||||
|
||||
namespace Yavsc.Models.Messaging;
|
||||
|
||||
public sealed class ClientEstimateSignedEvent : IEvent
|
||||
{
|
||||
public ClientEstimateSignedEvent(Estimate estimate, string sender)
|
||||
{
|
||||
EstimateId = estimate.Id;
|
||||
EstimateTitle = estimate.Title;
|
||||
PerformerId = estimate.OwnerId;
|
||||
Sender = sender;
|
||||
}
|
||||
|
||||
public long EstimateId { get; }
|
||||
public string EstimateTitle { get; }
|
||||
public string PerformerId { get; }
|
||||
public string Topic => "ClientEstimateSigned";
|
||||
public string Sender { get; set; }
|
||||
public string Title => "Devis signé par le client";
|
||||
public string CreateBody() => $"{Sender} a signé le devis n°{EstimateId} : {EstimateTitle}.";
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ public class BlogSpotService
|
|||
_context = context;
|
||||
this.fileSystemAuthManager = fileSystemAuthManager;
|
||||
this.siteSettings = siteSettings.Value;
|
||||
if (siteSettings.Value.Blog == null)
|
||||
if (siteSettings.Value.UserFilesRoot == null)
|
||||
{
|
||||
throw new InvalidOperationException("SiteSettings.Blog is not configured.");
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@ public class BlogSpotService
|
|||
{
|
||||
string blogFilesSubdir = $"blogs/{postId}";
|
||||
string destDir = Path.Combine(
|
||||
siteSettings.Blog,
|
||||
siteSettings.UserFilesRoot,
|
||||
user.UserName,
|
||||
blogFilesSubdir);
|
||||
var di = new DirectoryInfo(destDir);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ using Yavsc.Models.Google.Messaging;
|
|||
using Yavsc.Models.Haircut;
|
||||
using Yavsc.Models.Messaging;
|
||||
using Yavsc.Server.Hubs;
|
||||
using Yavsc.Abstract.Models.Messaging;
|
||||
|
||||
namespace Yavsc.Services
|
||||
{
|
||||
|
|
@ -30,7 +31,7 @@ namespace Yavsc.Services
|
|||
IHubContext<ChatHub> hubContext
|
||||
)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<MailSender>();
|
||||
_logger = loggerFactory.CreateLogger<YavscMessageSender>();
|
||||
_emailSender = emailSender;
|
||||
siteSettings = sitesOptions?.Value;
|
||||
this.hubContext = hubContext;
|
||||
|
|
@ -70,34 +71,51 @@ namespace Yavsc.Services
|
|||
};
|
||||
|
||||
var user = _dbContext.Users.FirstOrDefault(u => u.Id == userId);
|
||||
results.Add(result);
|
||||
if (user == null)
|
||||
{
|
||||
response.failure++;
|
||||
result.error = "no such user.";
|
||||
continue;
|
||||
}
|
||||
if (!user.EmailConfirmed)
|
||||
{
|
||||
response.failure++;
|
||||
result.error = "user has not confirmed his email address.";
|
||||
continue;
|
||||
}
|
||||
if (user.Email == null)
|
||||
{
|
||||
response.failure++;
|
||||
result.error = "user has no legacy email address.";
|
||||
continue;
|
||||
}
|
||||
|
||||
var body = ev.CreateBody();
|
||||
var subject = ev is ClientEstimateSignedEvent signed
|
||||
? signed.Title
|
||||
: $"{ev.Sender} (un client) vous demande un rendez-vous";
|
||||
|
||||
if (ev is RdvQueryEvent || ev is ClientEstimateSignedEvent)
|
||||
{
|
||||
_dbContext.Notification.Add(new Notification
|
||||
{
|
||||
title = subject.Length > 1024 ? subject[..1024] : subject,
|
||||
body = body.Length > 512 ? body[..512] : body,
|
||||
Target = $"user/{userId}",
|
||||
tag = ev switch
|
||||
{
|
||||
ClientEstimateSignedEvent estimateEvent => $"estimate/{estimateEvent.EstimateId}/client-signed",
|
||||
RdvQueryEvent rdvEvent => $"rdv/{rdvEvent.Id}",
|
||||
_ => throw new InvalidOperationException("Unsupported persistent notification event.")
|
||||
},
|
||||
click_action = "Fermer"
|
||||
});
|
||||
await _dbContext.SaveChangesAsync(ev.Sender);
|
||||
}
|
||||
|
||||
_logger.LogDebug($"Sending to {user.UserName} <{user.Email}> : {body}");
|
||||
|
||||
if (!user.EmailConfirmed || string.IsNullOrWhiteSpace(user.Email))
|
||||
{
|
||||
response.failure++;
|
||||
result.error = !user.EmailConfirmed
|
||||
? "user has not confirmed his email address."
|
||||
: "user has no legacy email address.";
|
||||
_logger.LogWarning("Email notification not sent to {UserId}: {Reason}", userId, result.error);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.message_id = await _emailSender.SendEmailAsync(user.UserName, user.Email,
|
||||
$"{ev.Sender} (un client) vous demande un rendez-vous",
|
||||
subject,
|
||||
body + Environment.NewLine);
|
||||
response.success++;
|
||||
}
|
||||
|
||||
var cxIds = _cxManager.GetConnexionIds(user.UserName);
|
||||
if (cxIds == null)
|
||||
|
|
@ -125,7 +143,6 @@ namespace Yavsc.Services
|
|||
|
||||
response.success++;
|
||||
}
|
||||
results.Add(result);
|
||||
}
|
||||
response.results = results.ToArray();
|
||||
return response;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ namespace Yavsc
|
|||
public string DataDir { get; set; }
|
||||
public string Avatars { get; set; }
|
||||
public long Quota { get; set; }
|
||||
public string Blog { get; set; }
|
||||
public string UserFilesRoot { get; set; }
|
||||
public string Bills { get; set; }
|
||||
public string GitRepository { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Api.Test.Fixtures;
|
||||
using Yavsc.ApiControllers;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Billing;
|
||||
|
||||
namespace Yavsc.Api.Test;
|
||||
|
||||
[Collection("Yavsc Api")]
|
||||
public sealed class BillingSignatureNotificationTests(ApiWebServerFixture fixture) : IClassFixture<ApiWebServerFixture>
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("bob", "bob", true)]
|
||||
[InlineData("alice", "alice", false)]
|
||||
[InlineData("alice", "bob", false)]
|
||||
public async Task Json_signature_notifies_only_when_the_authenticated_client_signs(
|
||||
string caller, string signer, bool notifies)
|
||||
{
|
||||
fixture.ResetAndSeedActivityGraph();
|
||||
using var scope = fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var estimate = new Estimate
|
||||
{
|
||||
ClientId = "bob", OwnerId = "alice", CommandType = BillingCodes.Rdv,
|
||||
Title = "Devis notification", Description = "Devis de test",
|
||||
AttachedFiles = new List<string>(), AttachedGraphics = new List<string>()
|
||||
};
|
||||
db.Estimates.Add(estimate);
|
||||
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
var controller = ActivatorUtilities.CreateInstance<BillingController>(scope.ServiceProvider);
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("sub", caller) }, "test"))
|
||||
}
|
||||
};
|
||||
controller.Url = new SignatureUrlHelper();
|
||||
var result = await controller.Sign(estimate.Id, new SignatureSubmission
|
||||
{
|
||||
SignerUserId = signer, CoordinateMax = 10000, Strokes = new[] { 1, 5000, 5000 }
|
||||
}, TestContext.Current.CancellationToken);
|
||||
if (caller != signer)
|
||||
{
|
||||
Assert.IsType<ForbidResult>(result);
|
||||
Assert.Empty(db.Signatures);
|
||||
}
|
||||
else
|
||||
Assert.IsType<CreatedResult>(result);
|
||||
|
||||
var delivery = fixture.Services.GetRequiredService<RecordingMessageDelivery>();
|
||||
if (notifies)
|
||||
{
|
||||
Assert.Equal("user/alice", Assert.Single(db.Notification).Target);
|
||||
Assert.Equal("alice@example.test", Assert.Single(delivery.Emails).Recipient);
|
||||
Assert.NotEqual(default, estimate.ClientValidationDate);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Empty(db.Notification);
|
||||
Assert.Empty(delivery.Emails);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SignatureUrlHelper : IUrlHelper
|
||||
{
|
||||
public ActionContext ActionContext => throw new NotSupportedException();
|
||||
public string? Action(UrlActionContext actionContext) => "/api/v1/bill/estimate/sign";
|
||||
public string? Content(string? contentPath) => throw new NotSupportedException();
|
||||
public bool IsLocalUrl(string? url) => throw new NotSupportedException();
|
||||
public string? Link(string? routeName, object? values) => throw new NotSupportedException();
|
||||
public string? RouteUrl(UrlRouteContext routeContext) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,9 @@ using Yavsc.Models.Relationship;
|
|||
using Yavsc.Models.Workflow;
|
||||
using Yavsc.Services;
|
||||
using Yavsc.Tests.Shared;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Yavsc.Interface;
|
||||
using Yavsc.Server.Hubs;
|
||||
|
||||
namespace Yavsc.Api.Test.Fixtures;
|
||||
|
||||
|
|
@ -64,8 +67,13 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
|||
|
||||
builder.Services.AddLocalization();
|
||||
builder.Services.Configure<GoogleAuthSettings>(_ => { });
|
||||
builder.Services.Configure<SiteSettings>(settings => settings.Authority = "example.test");
|
||||
builder.Services.AddTransient<IBillingService, BillingService>();
|
||||
builder.Services.AddTransient<IYavscMessageSender, NoopMessageSender>();
|
||||
builder.Services.AddSingleton<RecordingMessageDelivery>();
|
||||
builder.Services.AddSingleton<ITrueEmailSender>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
|
||||
builder.Services.AddSingleton<IHubContext<ChatHub>>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
|
||||
builder.Services.AddSingleton<IConnexionManager, HubConnectionManager>();
|
||||
builder.Services.AddTransient<IYavscMessageSender, YavscMessageSender>();
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("AdministratorOnly", policy =>
|
||||
|
|
@ -204,23 +212,9 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
|||
return builder.ToString();
|
||||
}
|
||||
|
||||
private sealed class NoopMessageSender : IYavscMessageSender
|
||||
{
|
||||
public Task<MessageWithPayloadResponse> NotifyBookQueryAsync(IEnumerable<string> connectionIds, RdvQueryEvent ev)
|
||||
=> Task.FromResult(new MessageWithPayloadResponse());
|
||||
|
||||
public Task<MessageWithPayloadResponse> NotifyEstimateAsync(IEnumerable<string> connectionIds, EstimationEvent ev)
|
||||
=> Task.FromResult(new MessageWithPayloadResponse());
|
||||
|
||||
public Task<MessageWithPayloadResponse> NotifyHairCutQueryAsync(IEnumerable<string> connectionIds, HairCutQueryEvent ev)
|
||||
=> Task.FromResult(new MessageWithPayloadResponse());
|
||||
|
||||
public Task<MessageWithPayloadResponse> NotifyAsync(IEnumerable<string> connectionIds, IEvent yaev)
|
||||
=> Task.FromResult(new MessageWithPayloadResponse());
|
||||
}
|
||||
|
||||
public void ResetAndSeedActivityGraph()
|
||||
{
|
||||
Services.GetRequiredService<RecordingMessageDelivery>().Clear();
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
|
||||
|
|
@ -334,6 +328,8 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
|||
// CommandLine reference NominativeServiceCommand (CommandId) and
|
||||
// must be deleted before the Rdv/HairCut/HairMultiCut queries.
|
||||
db.Set<CommandLine>().RemoveRange(db.Set<CommandLine>());
|
||||
db.DismissClicked.RemoveRange(db.DismissClicked);
|
||||
db.Notification.RemoveRange(db.Notification);
|
||||
db.Set<Estimate>().RemoveRange(db.Set<Estimate>());
|
||||
db.Set<RdvQuery>().RemoveRange(db.Set<RdvQuery>());
|
||||
db.Set<HairCutQuery>().RemoveRange(db.Set<HairCutQuery>());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
using System.Collections.Concurrent;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Yavsc.Interface;
|
||||
using Yavsc.Server.Hubs;
|
||||
|
||||
namespace Yavsc.Api.Test.Fixtures;
|
||||
|
||||
public sealed class RecordingMessageDelivery : ITrueEmailSender, IHubContext<ChatHub>, IHubClients
|
||||
{
|
||||
public record Email(string Recipient, string Subject, string Body);
|
||||
public record Push(string ConnectionId, string Method, object?[] Arguments);
|
||||
|
||||
public ConcurrentQueue<Email> Emails { get; } = new();
|
||||
public ConcurrentQueue<Push> Pushes { get; } = new();
|
||||
IHubClients IHubContext<ChatHub>.Clients => this;
|
||||
IGroupManager IHubContext<ChatHub>.Groups => throw new NotSupportedException();
|
||||
public IClientProxy All => throw new NotSupportedException();
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => throw new NotSupportedException();
|
||||
public IClientProxy Client(string connectionId) => new RecordingClient(this, connectionId);
|
||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => throw new NotSupportedException();
|
||||
public IClientProxy Group(string groupName) => throw new NotSupportedException();
|
||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => throw new NotSupportedException();
|
||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => throw new NotSupportedException();
|
||||
public IClientProxy User(string userId) => throw new NotSupportedException();
|
||||
public IClientProxy Users(IReadOnlyList<string> userIds) => throw new NotSupportedException();
|
||||
|
||||
public Task<string> SendEmailAsync(string name, string email, string subject, string htmlMessage)
|
||||
{
|
||||
Emails.Enqueue(new Email(email, subject, htmlMessage));
|
||||
return Task.FromResult(Guid.NewGuid().ToString());
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Emails.Clear();
|
||||
Pushes.Clear();
|
||||
}
|
||||
|
||||
private sealed class RecordingClient(RecordingMessageDelivery delivery, string connectionId) : IClientProxy
|
||||
{
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
{
|
||||
delivery.Pushes.Enqueue(new Push(connectionId, method, args));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ using Yavsc.Models;
|
|||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Models.Workflow;
|
||||
using Yavsc.Tests.Shared;
|
||||
using Yavsc.Services;
|
||||
|
||||
namespace Yavsc.Api.Test;
|
||||
|
||||
|
|
@ -138,6 +139,8 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
|||
|
||||
// Default caller is "alice" (the provider) → ProAccepted.
|
||||
Assert.Equal(QueryStatus.ProAccepted, updated.Status);
|
||||
Assert.Empty(assertDb.Notification);
|
||||
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -165,6 +168,8 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
|||
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
||||
Assert.Equal(SignatureType.Pro, signature.Type);
|
||||
Assert.Equal("alice", signature.SignerId);
|
||||
Assert.Empty(assertDb.Notification);
|
||||
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -192,6 +197,48 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
|||
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
||||
Assert.Equal(SignatureType.Client, signature.Type);
|
||||
Assert.Equal("bob", signature.SignerId);
|
||||
var notification = Assert.Single(assertDb.Notification);
|
||||
Assert.Equal("user/alice", notification.Target);
|
||||
Assert.Equal($"estimate/{estimateId}/client-signed", notification.tag);
|
||||
Assert.Contains("bob", notification.body);
|
||||
Assert.Contains("Devis prestation", notification.body);
|
||||
var email = Assert.Single(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
|
||||
Assert.Equal("alice@example.test", email.Recipient);
|
||||
Assert.Equal("Devis signé par le client", email.Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_signature_pushes_to_the_performer_even_without_confirmed_email()
|
||||
{
|
||||
var (queryId, estimateId) = SeedQuery(withEstimate: true);
|
||||
using (var scope = _fixture.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
db.Users.Single(u => u.Id == "alice").EmailConfirmed = false;
|
||||
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
var manager = _fixture.Services.GetRequiredService<IConnexionManager>();
|
||||
manager.OnConnected("signed-alice", "alice", false);
|
||||
try
|
||||
{
|
||||
using var http = NewClient("bob");
|
||||
var response = await http.PostAsync(
|
||||
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
||||
WithSignature(), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var delivery = _fixture.Services.GetRequiredService<RecordingMessageDelivery>();
|
||||
Assert.Empty(delivery.Emails);
|
||||
var push = Assert.Single(delivery.Pushes);
|
||||
Assert.Equal("signed-alice", push.ConnectionId);
|
||||
Assert.Equal("ClientEstimateSigned", push.Arguments[0]);
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
Assert.Equal($"estimate/{estimateId}/client-signed", Assert.Single(db.Notification).tag);
|
||||
}
|
||||
finally
|
||||
{
|
||||
manager.OnDisconnected("signed-alice");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ using Yavsc;
|
|||
using Yavsc.Api.Test.Fixtures;
|
||||
using Yavsc.Models.Workflow;
|
||||
using Yavsc.Tests.Shared;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Services;
|
||||
|
||||
namespace Yavsc.Api.Test;
|
||||
|
||||
|
|
@ -34,6 +37,72 @@ public sealed class RdvQueryApiControllerTests : IClassFixture<ApiWebServerFixtu
|
|||
return http;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuery_notifies_performer_by_database_email_and_signalr()
|
||||
{
|
||||
_fixture.ResetAndSeedRdvQueryGraph();
|
||||
var manager = _fixture.Services.GetRequiredService<IConnexionManager>();
|
||||
manager.OnConnected("rdv-alice", "alice", false);
|
||||
try
|
||||
{
|
||||
using var http = NewClient("bob");
|
||||
var response = await http.PostAsJsonAsync("/api/v1/billing/Rdv", new
|
||||
{
|
||||
ActivityCode = "dev", PerformerId = "alice", ClientId = "spoofed",
|
||||
Consent = true, EventDate = DateTime.UtcNow.AddDays(1),
|
||||
Location = new { Address = "Notification test", Latitude = 48.0, Longitude = 2.0 },
|
||||
Reason = "Demande notifiée", Status = QueryStatus.Inserted
|
||||
}, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var query = await response.Content.ReadFromJsonAsync<RdvQuery>(TestContext.Current.CancellationToken);
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var notification = Assert.Single(db.Notification);
|
||||
Assert.Equal("user/alice", notification.Target);
|
||||
Assert.Equal($"rdv/{query!.Id}", notification.tag);
|
||||
Assert.Contains("bob", notification.title);
|
||||
var delivery = _fixture.Services.GetRequiredService<RecordingMessageDelivery>();
|
||||
Assert.Equal("alice@example.test", Assert.Single(delivery.Emails).Recipient);
|
||||
var push = Assert.Single(delivery.Pushes);
|
||||
Assert.Equal("rdv-alice", push.ConnectionId);
|
||||
Assert.Equal("push", push.Method);
|
||||
Assert.Equal("/topic/RdvQuery", push.Arguments[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
manager.OnDisconnected("rdv-alice");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(true, false)]
|
||||
public async Task PostQuery_respects_performer_contact_preferences(bool acceptsNotifications, bool acceptsPublicContact)
|
||||
{
|
||||
_fixture.ResetAndSeedRdvQueryGraph();
|
||||
using (var scope = _fixture.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var performer = db.Performers.Single(p => p.PerformerId == "alice");
|
||||
performer.AcceptNotifications = acceptsNotifications;
|
||||
performer.AcceptPublicContact = acceptsPublicContact;
|
||||
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
using var http = NewClient("bob");
|
||||
var response = await http.PostAsJsonAsync("/api/v1/billing/Rdv", new
|
||||
{
|
||||
ActivityCode = "dev", PerformerId = "alice", Consent = true,
|
||||
EventDate = DateTime.UtcNow.AddDays(1),
|
||||
Location = new { Address = "Contact refused", Latitude = 48.0, Longitude = 2.0 },
|
||||
Reason = "Demande", Status = QueryStatus.Inserted
|
||||
}, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
using var assertScope = _fixture.Services.CreateScope();
|
||||
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
Assert.Empty(assertDb.Notification);
|
||||
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Billing_rdv_route_supports_crud()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using System.Net.Http.Json;
|
|||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Abstract.Models.Messaging;
|
||||
using Yavsc.Abstract.Resources;
|
||||
using Yavsc.Api.Test.Fixtures;
|
||||
using Yavsc.Server.Helpers;
|
||||
|
|
@ -98,6 +99,66 @@ public sealed class ResourceUsageApiControllerTests : IClassFixture<ApiWebServer
|
|||
Assert.True(record.BandwidthMb >= 3.5m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentUserUsage_records_telemetry_values_from_authenticated_requests()
|
||||
{
|
||||
using var http = NewClient(_fixture, "telemetry-user");
|
||||
|
||||
var response = await http.GetAsync("/api/v1/resource-usage/me", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
|
||||
var record = await db.ResourceUsageRecords
|
||||
.SingleAsync(r => r.UserId == "telemetry-user");
|
||||
|
||||
Assert.True(record.ApiCalls >= 1m);
|
||||
Assert.True(record.BandwidthMb > 0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentUserNotifications_returns_public_and_targeted_notifications()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
|
||||
|
||||
db.Notification.RemoveRange(db.Notification);
|
||||
await db.Notification.AddRangeAsync(
|
||||
new Notification
|
||||
{
|
||||
title = "Public",
|
||||
body = "Seen by everyone",
|
||||
click_action = "/public",
|
||||
Target = null,
|
||||
},
|
||||
new Notification
|
||||
{
|
||||
title = "User",
|
||||
body = "Private to current user",
|
||||
click_action = "/profile",
|
||||
Target = "user/telemetry-user",
|
||||
},
|
||||
new Notification
|
||||
{
|
||||
title = "Admin",
|
||||
body = "Restricted to administrators",
|
||||
click_action = "/admin",
|
||||
Target = "administration",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
using var http = NewClient(_fixture, "telemetry-user");
|
||||
|
||||
var response = await http.GetAsync("/api/v1/notifications/me", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var notifications = await response.Content.ReadFromJsonAsync<List<Notification>>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(notifications);
|
||||
Assert.Contains(notifications!, n => n.title == "Public");
|
||||
Assert.Contains(notifications!, n => n.title == "User");
|
||||
Assert.DoesNotContain(notifications!, n => n.title == "Admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAdminOverview_returns_latest_usage_snapshot_for_each_user()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -91,8 +91,12 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
|
|||
return file.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller()
|
||||
[Theory]
|
||||
[InlineData("note.txt", "note.txt")]
|
||||
[InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF",
|
||||
"INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")]
|
||||
public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller(
|
||||
string originalName, string storedName)
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
// DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense
|
||||
|
|
@ -105,7 +109,7 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
|
|||
using var form = new MultipartFormDataContent();
|
||||
var fileContent = new ByteArrayContent(payload);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
||||
form.Add(fileContent, "file", "note.txt");
|
||||
form.Add(fileContent, "file", originalName);
|
||||
|
||||
using var http = NewClient("alice");
|
||||
var response = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken);
|
||||
|
|
@ -117,18 +121,17 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
|
|||
var item = Assert.Single(doc.RootElement.EnumerateArray());
|
||||
var fileId = item.GetProperty("fileId").GetInt64();
|
||||
Assert.True(fileId > 0, $"Expected a positive fileId, got {fileId}. Body: {body}");
|
||||
Assert.Equal("note.txt", item.GetProperty("fileName").GetString());
|
||||
Assert.Equal(storedName, item.GetProperty("fileName").GetString());
|
||||
Assert.False(item.GetProperty("quotaOffense").GetBoolean());
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var row = Assert.Single(db.UploadedFiles.Where(u => u.OwnerId == "alice"));
|
||||
Assert.Equal("note.txt", row.Path);
|
||||
Assert.Equal(storedName, row.Path);
|
||||
Assert.Equal("text/plain", row.ContentType);
|
||||
Assert.Equal(payload.Length, row.Length);
|
||||
|
||||
// The file physically landed under {BlogFilesRoot}/alice/note.txt.
|
||||
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")),
|
||||
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", storedName)),
|
||||
"Expected the uploaded file on disk under the owner's personal root");
|
||||
}
|
||||
|
||||
|
|
|
|||
76
src/test/Yavsc.Blogs.Tests/FilterFileNameTests.cs
Normal file
76
src/test/Yavsc.Blogs.Tests/FilterFileNameTests.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
using System.Net;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Server.Helpers;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
public sealed class FilterFileNameTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("note.txt", "note.txt")]
|
||||
[InlineData("ABC-123=_~.PDF", "ABC-123=_~.PDF")]
|
||||
[InlineData("Troisi\u00e8me.PDF", "Troisi_232me.PDF")]
|
||||
[InlineData("\u00e9\u00e0\u00e7.pdf", "_233_224_231.pdf")]
|
||||
[InlineData("folder/file\\name.pdf", "folder_047file_092name.pdf")]
|
||||
public void Filter_encodes_unsupported_characters_without_URL_fragments(
|
||||
string originalName, string expectedName)
|
||||
{
|
||||
var filtered = AbstractFileSystemHelpers.FilterFileName(originalName);
|
||||
|
||||
Assert.Equal(expectedName, filtered);
|
||||
Assert.DoesNotContain("#", filtered);
|
||||
Assert.True(filtered.IsValidYavscPath());
|
||||
Assert.Equal(filtered, AbstractFileSystemHelpers.FilterFileName(filtered));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uploaded_accented_filename_is_accessible_through_static_files()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"yavsc-filename-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(root);
|
||||
try
|
||||
{
|
||||
var payload = System.Text.Encoding.UTF8.GetBytes("accented filename regression");
|
||||
using var input = new MemoryStream(payload);
|
||||
var file = new FormFile(input, 0, payload.Length, "file",
|
||||
"INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF")
|
||||
{
|
||||
Headers = new HeaderDictionary(),
|
||||
ContentType = "application/pdf"
|
||||
};
|
||||
var user = new ApplicationUser { DiskQuota = 10_000_000 };
|
||||
var received = user.ReceiveUserFile(root, file);
|
||||
Assert.False(received.QuotaOffense);
|
||||
Assert.Equal("INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF",
|
||||
received.FileName);
|
||||
|
||||
using var provider = new PhysicalFileProvider(root);
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
await using var app = builder.Build();
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = provider,
|
||||
RequestPath = "/files"
|
||||
});
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
using var http = app.GetTestClient();
|
||||
var uri = new Uri(http.BaseAddress!, $"/files/{received.FileName}");
|
||||
Assert.Equal(string.Empty, uri.Fragment);
|
||||
using var response = await http.GetAsync(uri, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/pdf", response.Content.Headers.ContentType?.MediaType);
|
||||
Assert.Equal(payload,
|
||||
await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -131,7 +131,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture
|
|||
// on real files.
|
||||
builder.Services.Configure<SiteSettings>(s =>
|
||||
{
|
||||
s.Blog = BlogFilesRoot;
|
||||
s.UserFilesRoot = BlogFilesRoot;
|
||||
});
|
||||
|
||||
// Real BlogSpotService — same instance the production host
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFix
|
|||
// BlogsWebServerFixture.
|
||||
builder.Services.Configure<SiteSettings>(s =>
|
||||
{
|
||||
s.Blog = Path.Combine(Path.GetTempPath(), "postit-blogs-mappedclaims-" + Guid.NewGuid().ToString("N"));
|
||||
s.UserFilesRoot = Path.Combine(Path.GetTempPath(), "postit-blogs-mappedclaims-" + Guid.NewGuid().ToString("N"));
|
||||
});
|
||||
builder.Services.AddScoped<BlogSpotService>();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"FavIcon": "/favicon.ico",
|
||||
"Icon": "/images/yavsc.png",
|
||||
"GitRepository": "testingrepo",
|
||||
"Blog": "blog",
|
||||
"UserFilesRoot": "files",
|
||||
"Bills": "bills",
|
||||
"TempDir": "temp",
|
||||
"Avatars": "avatars",
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ public sealed class ServerServicesFixture : IDisposable
|
|||
// Details itself doesn't read it, but the service constructor
|
||||
// throws if SiteSettings.Blog is null, so configure a temp
|
||||
// value to keep construction alive.
|
||||
services.Configure<SiteSettings>(s => s.Blog = "server-tests-files-root");
|
||||
services.Configure<SiteSettings>(s => s.UserFilesRoot = "server-tests-files-root");
|
||||
|
||||
// Trivial file-system auth: Details never calls into it, but
|
||||
// the DI container needs an instance to construct the service.
|
||||
|
|
|
|||
Loading…
Reference in a new issue