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 |
|||
|
ebdf51f468 |
|||
|
cd7351a783 |
|||
|
95e8f251d2 |
|||
|
e7721bc3ab |
|||
|
3f73d7a909 |
|||
|
a3207352c3 |
|||
|
12f8b52173 |
|||
|
1faa80c884 |
|||
|
f050ee4456 |
|||
|
5e22b9a232 |
|||
|
008295c8f6 |
|||
|
6b75ae7682 |
|||
|
05e35d0677 |
|||
|
137665bfe0 |
|||
|
af1e572f32 |
|||
|
f8c2a01ef4 |
|||
|
a843e77ae9 |
|||
|
0c72c49ec1 |
|||
|
7d10060efd |
|||
|
4ff072ba06 |
|||
|
eeae7379e6 |
|||
|
8b61887865 |
|||
|
1897a6807d |
|||
|
bba5fe093c |
|||
|
7e9e7613ba |
|||
|
84c98cb938 |
|||
|
9369ff4fcd |
|||
|
51e445b5ba |
|||
|
75bb2600b5 |
|||
|
b22a26f61e |
|||
|
bb773ba29a |
|||
|
251cc9cd48 |
|||
|
73528d0c7c |
|||
|
f680cf6827 |
|||
|
68b224323b |
|||
|
eacc4697c9 |
|||
|
1e1b751906 |
|||
|
edac506c49 |
|||
|
549bae3b5f |
|||
|
9fc196f9eb |
|||
|
4a0c59e105 |
|||
|
46fa53c513 |
|||
|
c1f447636b |
|||
|
94a9239cd5 |
|||
|
a94a5a0497 |
|||
|
8c8ab70d38 |
|||
|
f0792a844f |
|||
|
26e4fde1e7 |
|||
|
70f8b7986e |
|||
|
1d12166611 |
|||
|
5104cbc3f1 |
|||
|
7844449c99 |
|||
|
f1a17cd8fe |
|||
|
4554886e33 |
|||
|
a307885310 |
|||
|
d8c13c81d7 |
|||
|
8d2d6754f6 |
|||
|
94d8eb538c |
|||
|
daf5949392 |
|||
|
2c8be78175 |
|||
|
e1ce67a76e |
|||
|
384f6bc300 |
|||
|
a17a9cef9a |
|||
|
ed3dc733d3 |
|||
|
5553f2f290 |
|||
|
3fe7b8b46c |
|||
|
a793996b70 |
|||
|
8e3862ed26 |
|||
|
70ab05522b |
|||
|
eb58fc8862 |
|||
|
040446c0b8 |
|||
|
bad09cc7af |
|||
|
2c36d880e1 |
|||
|
f0d262cb37 |
|||
|
1cf3c689d8 |
|||
|
fbf42cc1db |
|||
|
ba40698cc9 |
|||
|
35d784fdf1 |
|||
|
9c1f552bca |
|||
|
bbc37d3095 |
|||
|
7caae103fe |
|||
|
f44dab0334 |
|||
|
97d0584292 |
|||
|
70a123b840 |
|||
|
adc27a006b |
|||
|
827fe6b41f |
|||
|
36ffe4683c |
|||
|
2ad7a55e34 |
|||
|
709846c58d |
|||
|
9aef10f281 |
|||
|
e28959b068 |
|||
|
22c33c5388 |
|||
|
60c10781f0 |
|||
|
625e062b4b |
|||
|
1455ba5187 |
|||
|
605d885917 |
|||
|
efc2296858 |
|||
|
6a69a20364 |
|||
|
f09c657183 |
|||
|
93fd4e61ad |
|||
|
d5f11e83a4 |
|||
|
11ae66f85b |
|||
|
8d1348197d |
|||
|
483abb78ec |
|||
|
b3c2674235 |
|||
|
db5c202918 |
|||
|
1773c5aaea |
|||
|
67da921bce |
|||
| fb0d9589cf | |||
|
c450bd7ff8 |
|||
|
827cf7d2a3 |
|||
|
44f0664a5d |
|||
|
a12830e49b |
|||
|
2d8f92dde9 |
|||
|
156b9c46e1 |
|||
|
1a968346a4 |
|||
|
574616c786 |
|||
|
bc6bebea06 |
|||
|
9e8d00d78f |
|||
|
ac1ba2ee68 |
|||
|
eebd46176d |
|||
|
3bc4142383 |
|||
|
3e7e76edf2 |
|||
|
96254b52cd |
|||
|
d47dd0f4ba |
|||
|
51a2a40c88 |
|||
|
2b1c3d2a9b |
|||
|
ef9ee2fb87 |
|||
|
110ed279f5 |
|||
| 48cacb74cb | |||
|
48ffe835df |
|||
|
2cd24dd383 |
|||
|
c3c5494507 |
|||
|
75e1499bb1 |
|||
|
0c5bd61991 |
|||
|
2484876c5b |
|||
|
848f8be42f |
|||
|
f70f487777 |
|||
|
1af839e14e |
|||
|
f7e1f4a736 |
|||
|
7c565d3af9 |
|||
|
9832d28173 |
|||
|
cbb59f019d |
|||
|
67715f9d67 |
|||
|
d92456e141 |
|||
|
5b48e0bfdb |
|||
|
3836faf872 |
|||
|
2397f4d3a8 |
|||
|
86091990c5 |
|||
|
ddff760c41 |
|||
|
344c38704b |
|||
|
af1a0e0bf9 |
|||
|
c665a3e95d |
|||
|
3824a928fe |
|||
|
77e521b96d |
|||
|
91fc7f168b |
|||
|
3626be3d8b |
|||
|
7b842a9fe9 |
|||
|
b75d0add7d |
|||
|
555087232c |
|||
|
41d8fad8c3 |
|||
|
d8c0678462 |
|||
|
93426986f6 |
|||
|
ec87e8db15 |
|||
|
bb4792d8ae |
|||
|
f7d5b21d66 |
|||
|
e79fba7b66 |
|||
|
d65f78d985 |
|||
|
7a4ca76957 |
|||
|
c902852a24 |
|||
|
38bc659a58 |
|||
|
10fdcb288c |
|||
|
229971783a |
445 changed files with 64159 additions and 5153 deletions
|
|
@ -15,7 +15,7 @@ POSTGRES_HOST=localhost
|
|||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=db
|
||||
POSTGRES_USER=pguser
|
||||
POSTGRES_PASSWORD=pgpassword
|
||||
POSTGRES_PASSWORD=<your pg password>
|
||||
|
||||
ANTHROPIC_API_KEY=none
|
||||
ANTHROPIC_MAX_TOKENS=0
|
||||
|
|
|
|||
|
|
@ -19,20 +19,21 @@ on:
|
|||
required: false
|
||||
type: boolean
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
branches: [ "main", "release/*", "hotfix/*", "fix/*" ]
|
||||
pull_request:
|
||||
branches: [ "main", "release/*" ]
|
||||
branches: [ "main", "release/*", "hotfix/*", "fix/*" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1
|
||||
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4
|
||||
steps:
|
||||
- name: Clone yavsc
|
||||
run: |
|
||||
set -e
|
||||
cd /src
|
||||
git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src
|
||||
git clone "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src
|
||||
cd _src
|
||||
if [ -n "${GITHUB_REF:-}" ]; then
|
||||
git fetch origin "$GITHUB_REF"
|
||||
|
|
@ -40,12 +41,26 @@ jobs:
|
|||
fi
|
||||
git submodule update --init --recursive
|
||||
echo "✅ Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)"
|
||||
|
||||
- name: Secret scan
|
||||
run: |
|
||||
echo "🔍 Scanning for secrets..."
|
||||
cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts main..HEAD \
|
||||
- name: Build
|
||||
run: |
|
||||
echo "🏗️ Building the solution..."
|
||||
cd /src/_src && dotnet build --verbosity normal
|
||||
- name: Test
|
||||
run: |
|
||||
echo "🚀 Lancement des tests..."
|
||||
cd /src/_src && dotnet test \
|
||||
--verbosity normal \
|
||||
--filter="Category!=Platform-Android" \
|
||||
--logger "xunit;LogFileName=test-results.xml" \
|
||||
&& echo "✅ Success !" || echo "❌ Fail ($?)!"
|
||||
--report-xunit \
|
||||
--report-xunit-html \
|
||||
--report-xunit-html-filename "test-results.html"
|
||||
TEST_EXIT_CODE=$?
|
||||
echo "Test exit code: $TEST_EXIT_CODE"
|
||||
if [ $TEST_EXIT_CODE -ne 0 ]; then
|
||||
echo "❌ Tests failed with exit code $TEST_EXIT_CODE"
|
||||
exit $TEST_EXIT_CODE
|
||||
else
|
||||
echo "✅ All tests passed successfully."
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ jobs:
|
|||
release:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1
|
||||
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4
|
||||
steps:
|
||||
- name: Clone du repo au tag demandé
|
||||
env:
|
||||
|
|
@ -69,7 +69,7 @@ jobs:
|
|||
cd /src
|
||||
|
||||
if [[ ! -d _src/.git ]]; then
|
||||
git clone --depth=1 https://forgejo.pschneider.fr/notazof/yavsc.git _src
|
||||
git clone --depth=1 "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src
|
||||
fi
|
||||
|
||||
cd _src
|
||||
|
|
@ -200,6 +200,7 @@ jobs:
|
|||
RELEASE_BODY: ${{ env.RELEASE_BODY }}
|
||||
IS_PRERELEASE: ${{ env.IS_PRERELEASE }}
|
||||
run: |
|
||||
set -e
|
||||
if [[ -z "$TAG" ]]; then
|
||||
echo "::error::No tag resolved for the API call."
|
||||
exit 1
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -43,3 +43,9 @@ DataDir/
|
|||
|
||||
*.log
|
||||
|
||||
# gitleaks scan output (local secret-scan report, not for the repo)
|
||||
/gitleaks-report.json
|
||||
|
||||
# Exclude test reports (local test report, not for the repo)
|
||||
/test-reports
|
||||
/doc/**/.*.md
|
||||
|
|
|
|||
45
.gitleaksignore
Normal file
45
.gitleaksignore
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Exclure uniquement les dossiers de sortie de compilation
|
||||
bin/
|
||||
obj/
|
||||
src/*/bin/
|
||||
src/*/obj/
|
||||
test/*/bin/
|
||||
test/*/obj/
|
||||
|
||||
# Toolchain front (Node / esbuild)
|
||||
node_modules/
|
||||
build/
|
||||
package-lock.json
|
||||
|
||||
# Exclure les caches lourds
|
||||
.git/
|
||||
.vs/
|
||||
|
||||
.env
|
||||
|
||||
.*.env
|
||||
|
||||
*.csproj.lscache
|
||||
data/
|
||||
appsettings.*.json
|
||||
appsettings-*.*.json
|
||||
|
||||
# Exception: the Testing-environment override for Yavsc.Org is a tracked
|
||||
# configuration source, not a secrets file. TestWebApplicationFactory
|
||||
# (Yavsc.Org.Tests) flips ASPNETCORE_ENVIRONMENT to "Testing" so
|
||||
# AddConfiguration("org") in Program.Main loads this file as the
|
||||
# last in the chain (it is optional). It overrides the connection
|
||||
# string and SMTP section for the in-memory test host and contains
|
||||
# no production secrets.
|
||||
!src/Yavsc.Org/appsettings-org.Testing.json
|
||||
|
||||
generated/
|
||||
*.tmp
|
||||
tmp/
|
||||
DataDir/
|
||||
|
||||
*.tests.trx
|
||||
*.tests.html
|
||||
|
||||
*.log
|
||||
|
||||
6
.pre-commit-config.yaml
Normal file
6
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
repos:
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.24.2
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
|
||||
45
.vscode/launch.json
vendored
45
.vscode/launch.json
vendored
|
|
@ -4,13 +4,27 @@
|
|||
// Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "PostIt Desktop",
|
||||
"type": "dotnet",
|
||||
"request": "launch",
|
||||
"projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj"
|
||||
},
|
||||
{
|
||||
"name": "PostIt Desktop local",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/src/PostIt/PostIt.Desktop/bin/Debug/net10.0/PostIt.Desktop.dll",
|
||||
"env": {
|
||||
"POSTIT_SETTINGS_JSON": "/home/paul/Workspace/yavsc/src/PostIt/PostIt/postit-settings.json"
|
||||
},
|
||||
"preLaunchTask": "dotnet: build-postit-desktop"
|
||||
},
|
||||
{
|
||||
"name": "Android Debug",
|
||||
"type": "mono",
|
||||
"preLaunchTask": "run-debug-android",
|
||||
"request": "attach",
|
||||
"address": "localhost",
|
||||
"port": 55555
|
||||
"type": "maui",
|
||||
"request": "launch",
|
||||
"project": "${workspaceFolder}/src/PostIt/PostIt.Android/PostIt.Android.csproj"
|
||||
},
|
||||
{
|
||||
"name": "Android Attach - Debug",
|
||||
|
|
@ -20,16 +34,22 @@
|
|||
"port": 55555
|
||||
},
|
||||
{
|
||||
"name": "API",
|
||||
"name": "Yavsc API",
|
||||
"type": "dotnet",
|
||||
"request": "launch",
|
||||
"projectPath": "${workspaceFolder}/src/Api/Api.csproj"
|
||||
"projectPath": "${workspaceFolder}/src/Yavsc.Api/Yavsc.Api.csproj"
|
||||
},
|
||||
{
|
||||
"name": "Yavsc Org",
|
||||
"type": "dotnet",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj",
|
||||
"program": "${workspaceFolder}/src/Yavsc.Org/bin/Debug/net10.0/Yavsc.Org.dll",
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"cwd": "${workspaceFolder}/src/Yavsc.Org",
|
||||
"preLaunchTask": "build-web-yavsc-org"
|
||||
|
||||
},
|
||||
{
|
||||
"name": "Yavsc Blogs",
|
||||
|
|
@ -37,12 +57,7 @@
|
|||
"request": "launch",
|
||||
"projectPath": "${workspaceFolder}/src/Yavsc.Blogs/Yavsc.Blogs.csproj"
|
||||
},
|
||||
{
|
||||
"name": "PostIt Desktop",
|
||||
"type": "dotnet",
|
||||
"request": "launch",
|
||||
"projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj",
|
||||
},
|
||||
|
||||
{
|
||||
"name": "Test PostIt.Android launch (Xamarin.UITest)",
|
||||
"type": "coreclr",
|
||||
|
|
|
|||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
|
|
@ -45,5 +45,10 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"dotnet.defaultSolution": "yavsc.sln"
|
||||
"dotnet.defaultSolution": "yavsc.sln",
|
||||
"axaml.selectedSolution": "/home/paul/Workspace/yavsc/yavsc.sln",
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"dotnet build": true,
|
||||
"dotnet test": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
96
.vscode/tasks.json
vendored
96
.vscode/tasks.json
vendored
|
|
@ -1,27 +1,17 @@
|
|||
{
|
||||
"version": "2.0.0",
|
||||
"isRoot": true,
|
||||
"problemMatcher": [
|
||||
{
|
||||
"owner": "dotnet",
|
||||
"fileLocation": ["relative", "${workspaceFolder}"],
|
||||
"source": "dotnet",
|
||||
"pattern": {
|
||||
"regexp": "^\\s*(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.*)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"column": 3,
|
||||
"severity": 4,
|
||||
"code": 5,
|
||||
"message": 6
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
"tasks": [
|
||||
{
|
||||
"label": "run-debug-android",
|
||||
"label": "build-debug-android",
|
||||
"command": "dotnet",
|
||||
"detail": "Build the PostIt Android project in debug mode",
|
||||
"type": "shell",
|
||||
"runOptions": {
|
||||
|
||||
},
|
||||
"problemMatcher": ["$msCompile"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Android",
|
||||
"env": {
|
||||
|
|
@ -43,11 +33,15 @@
|
|||
"label": "build",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"problemMatcher": ["$msCompile"],
|
||||
"args": ["build", "/property:GenerateFullPaths=true"],
|
||||
"group": "build",
|
||||
"isBuildCommand": true,
|
||||
"isTestCommand": false,
|
||||
"isBackground": true
|
||||
"isBackground": true,
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "test blogs backend",
|
||||
|
|
@ -63,6 +57,28 @@
|
|||
"isDefault": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "test api backend (npgsql)",
|
||||
"type": "process",
|
||||
"problemMatcher": "$msCompile",
|
||||
"command": "dotnet",
|
||||
"args": [
|
||||
"test",
|
||||
"Yavsc.Api.Test.csproj",
|
||||
"-v",
|
||||
"minimal"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "src/Yavsc.Api.Test",
|
||||
"env": {
|
||||
"YAVSC_API_TEST_DB_PROVIDER": "npgsql"
|
||||
}
|
||||
},
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "build-webapi",
|
||||
"type": "process",
|
||||
|
|
@ -76,6 +92,50 @@
|
|||
"kind": "build"
|
||||
},
|
||||
"isBackground": true
|
||||
},
|
||||
{
|
||||
"label": "build-web-yavsc-org",
|
||||
"type": "process",
|
||||
"problemMatcher": ["$msCompile"],
|
||||
"command": "dotnet",
|
||||
"args": ["build"],
|
||||
"options": {
|
||||
"cwd": "src/Yavsc.Org"
|
||||
},
|
||||
"group": {
|
||||
"kind": "build"
|
||||
},
|
||||
"isBackground": true
|
||||
},
|
||||
{
|
||||
"label": "build-web-yavsc-blogs",
|
||||
"type": "process",
|
||||
"problemMatcher": ["$msCompile"],
|
||||
"command": "dotnet",
|
||||
"args": ["build"],
|
||||
"options": {
|
||||
"cwd": "src/Yavsc.Blogs"
|
||||
},
|
||||
"group": {
|
||||
"kind": "build"
|
||||
},
|
||||
"isBackground": true
|
||||
},
|
||||
{
|
||||
"label": "dotnet: build-postit-desktop",
|
||||
"type": "process",
|
||||
"isBuildCommand": true,
|
||||
"isTestCommand": false,
|
||||
"isBackground": true,
|
||||
"command": "dotnet",
|
||||
"problemMatcher": ["$msCompile"],
|
||||
"args": ["build", "/property:GenerateFullPaths=true"],
|
||||
"options": {
|
||||
"cwd": "src/PostIt/PostIt.Desktop"
|
||||
},
|
||||
"group": {
|
||||
"kind": "build"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
503
CHANGELOG.md
503
CHANGELOG.md
|
|
@ -1,5 +1,504 @@
|
|||
# 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
|
||||
|
||||
### Added
|
||||
|
||||
* [PostIt] Reconnection automatique aux salons de discussion au reconnect
|
||||
|
||||
### Changed
|
||||
|
||||
* [PostIt] La liste des billets et l'éditeur Markdown se partagent
|
||||
l'espace disponible et défilent séparément ; un séparateur permet
|
||||
de redimensionner les deux zones.
|
||||
|
||||
### Fixed
|
||||
|
||||
* [PostIt] Défilement des articles longs en édition et en aperçu :
|
||||
suppression de la limite de hauteur qui masquait le contenu Markdown
|
||||
et du défilement imbriqué autour d'AvaloniaEdit. Six tests headless
|
||||
couvrent les barres automatiques, le redimensionnement et le
|
||||
défilement indépendant de la liste.
|
||||
|
||||
## [1.0.8-rc23] - unstable
|
||||
|
||||
### Added
|
||||
|
||||
* [PostIt] Badge « Publié » à côté du titre des billets publiés dans la
|
||||
liste, mis à jour lors de la publication ou du retour en brouillon.
|
||||
* [PostIt] Gestion native du compte inspirée de `/Manage` : modification
|
||||
du nom d'utilisateur, du nom complet et de l'adresse, téléversement
|
||||
d'avatar, confirmation e-mail, préférence de courriel mensuel,
|
||||
création ou changement de mot de passe et activation/désactivation
|
||||
de l'authentification à deux facteurs.
|
||||
* [PostIt][Yavsc.Api] Sélection d'un agenda Google, gestion séparée des
|
||||
comptes bancaires, dissociation des connexions externes et suppression
|
||||
du compte avec confirmation du nom d'utilisateur.
|
||||
* [PostIt] Configuration du profil professionnel et des activités,
|
||||
navigation vers les cercles et les publications depuis le compte.
|
||||
* [PostIt][Yavsc.Api] Administration des utilisateurs avec recherche
|
||||
dans les métadonnées du profil et consultation de la consommation
|
||||
de ressources.
|
||||
* [Yavsc.Server][Yavsc.Api][Yavsc.Blogs] Suivi de la consommation de
|
||||
ressources, API de synthèse et migration `ResourceUsageRecords`.
|
||||
* [Tests] Couverture des parcours profil et professionnel, de l'API de
|
||||
consommation de ressources et du renouvellement des jetons :
|
||||
rotation à usage unique, appels simultanés, réponses 401 concurrentes
|
||||
et rejet d'un jeton de renouvellement révoqué.
|
||||
|
||||
### Changed
|
||||
|
||||
* [PostIt] Réorganisation de la page de compte en sections de profil,
|
||||
sécurité et préférences ; actions de modification désactivées tant
|
||||
que le profil n'est pas chargé ou qu'une opération est en cours.
|
||||
* [PostIt] Utilisation de `PlaceholderText` à la place de `Watermark`
|
||||
dans les pages de profil et de configuration professionnelle.
|
||||
* [Yavsc.Api] `GET account/me` renvoie le DTO partagé attendu par
|
||||
PostIt, avec l'état de sécurité, les connexions externes, les comptes
|
||||
bancaires et les compteurs du compte.
|
||||
|
||||
### Fixed
|
||||
|
||||
* [Yavsc.Api] Enregistrement des services ASP.NET Identity et des stores
|
||||
EF nécessaires à `UserManager<ApplicationUser>` : leur absence
|
||||
empêchait l'activation du contrôleur de compte et provoquait une 500.
|
||||
* [Yavsc.Api] Chargement du profil : réponse adaptée lorsqu'un jeton
|
||||
ne contient pas d'identifiant utilisateur ou que le compte n'existe
|
||||
plus, au lieu d'une exception serveur.
|
||||
* [PostIt] Synchronisation de tous les chemins de renouvellement des
|
||||
jetons, y compris après une 401 et au démarrage : les appels
|
||||
concurrents ne réutilisent plus le même `refresh_token`. Un rejet
|
||||
`invalid_grant` efface la session et invite à se reconnecter.
|
||||
* [PostIt] Effacement des informations obsolètes après un échec de
|
||||
rechargement du profil et normalisation des valeurs de calendrier
|
||||
lors de la sauvegarde.
|
||||
* [PostIt][Yavsc.Api] Préservation de l'identité du profil professionnel
|
||||
lors de son enregistrement.
|
||||
* [Yavsc.Api] Les coordonnées bancaires ne sont plus modifiées à partir
|
||||
d'un résumé agrégé : opérations dédiées avec contrôle de propriété
|
||||
et validation de longueur des champs.
|
||||
|
||||
### Known limitations
|
||||
|
||||
* [PostIt] L'ajout d'un nouveau fournisseur OAuth et la gestion des
|
||||
crédits ne sont pas encore disponibles dans l'interface native.
|
||||
|
||||
## [1.0.8-rc22] - unstable
|
||||
|
||||
### Fixed
|
||||
|
||||
User experience login into PostIt
|
||||
|
||||
## [1.0.8-rc21] - unstable
|
||||
|
||||
### Fixed
|
||||
|
||||
User experience editing the blog article.
|
||||
|
||||
## [1.0.8-rc20] - unstable
|
||||
|
||||
Chat SignalR fonctionnel de bout en bout (connexion, salons multiples,
|
||||
envoi/réception de messages), enregistreur audio et localisation dans
|
||||
PostIt, intégration Live.Avalonia démarrée, et reverse-proxy nginx
|
||||
apprêté pour WebSocket.
|
||||
|
||||
### Added
|
||||
|
||||
* [PostIt] Interface de chat : page `ChatPage` (connexion au hub,
|
||||
saisie du nom de salon, liste des messages et des membres présents),
|
||||
entrée depuis la page d'accueil.
|
||||
* [PostIt] Envoi de messages dans un salon joint : DTO `ChatMessage`
|
||||
(Name/Room/Message) pour l'événement `ReceiveMessage`, saisie avec
|
||||
envoi sur `Enter`, commande inactive tant qu'aucune salle n'est en
|
||||
focus (le hub refuse l'envoi hors salon, `LabNoJoinNoSend`).
|
||||
* [PostIt] Modèle multi-salles côté client : `JoinedRooms`,
|
||||
`SelectedRoom` (salle en focus), messages et utilisateurs indexés par
|
||||
salle (`FocusedMessages`/`FocusedUsers`), ComboBox de choix de la
|
||||
salle active ; un `Join` sur une salle déjà jointe ne fait que
|
||||
changer le focus.
|
||||
* [PostIt] Enregistreur audio (`AudioRecorder`, interface
|
||||
`IPCMProcessor`) et ressources de localisation
|
||||
(`Resources.{resx,en-EN,pt-PT}`).
|
||||
* [PostIt][WIP] Début d'intégration Live.Avalonia (preview XAML à
|
||||
chaud ; encore KO).
|
||||
* [contrib] Bloc `location /chathub` dans `template.vhost` : upgrade
|
||||
WebSocket (`Upgrade`/`Connection`), `proxy_read_timeout 3600s` et
|
||||
`proxy_buffering off` — sans quoi la connexion SignalR tombait au
|
||||
premier silence ou l'upgrade échouait derrière nginx.
|
||||
|
||||
### Changed
|
||||
|
||||
* [PostIt] `StatusNotice` déplacé de `Models` vers `ViewModels` et
|
||||
enrichi d'une commande d'action optionnelle (`CheckCommand` +
|
||||
`CheckCommandLabel`, interface `IWithLabel`) : le bandeau de statut
|
||||
propose désormais l'action contextuelle (Connecter / Déconnecter /
|
||||
Reconnecter / Vérifier la connexion).
|
||||
* [PostIt] `ChatViewModel` suit le cycle de vie complet de la
|
||||
connexion : handlers `Reconnecting` / `Reconnected` / `Closed` de
|
||||
`WithAutomaticReconnect`, pour refléter l'état réel au lieu d'un
|
||||
`IsConnected` optimiste.
|
||||
* [Yavsc.Api] `AddSignalR().AddJsonProtocol(IncludeFields = true)` :
|
||||
`ChatRoomInfo` n'expose que des champs publics, que System.Text.Json
|
||||
ne sérialise pas par défaut — le client recevait des objets vides
|
||||
(`Name` null, `Users` vide) et la liste des salons joints n'était
|
||||
jamais alimentée.
|
||||
* [Yavsc.Server] `IConnexionManager.OnConnected` prend le `userName`
|
||||
en paramètre (résolu depuis les claims dans `ChatHub`) au lieu de le
|
||||
relire depuis un dictionnaire jamais alimenté.
|
||||
* [PostIt.Android] `network_security_config.xml` déplacé sous
|
||||
`Resources/xml/` (chemin canonique Android).
|
||||
|
||||
### Fixed
|
||||
|
||||
* [Yavsc.Server] `KeyNotFoundException` dans
|
||||
`HubConnectionManager.OnConnected` à chaque connexion SignalR :
|
||||
`ChatUserNames` (et `ChatCxIds`) n'étaient jamais alimentés — relique
|
||||
d'un `SetUserName` disparu lors d'une refactorisation. L'exception
|
||||
dans `OnConnectedAsync` faisait fermer la connexion par le hub, et
|
||||
PostIt affichait « Déconnecté (The server closed the connection…) »
|
||||
immédiatement après la connexion.
|
||||
* [PostIt] URL du hub SignalR : retrait du préfixe `/api/v1` de
|
||||
`ApiUrl` avant d'apposer `Constants.ChatHubPath` (le hub est mappé à
|
||||
la racine du service, pas sous le préfixe versionné).
|
||||
* [PostIt] La `StatusBar` de la page chat référençait `Grid.Row="4"`
|
||||
sur une grille à 3 rangées (rangée hors limites, bandeau invisible).
|
||||
|
||||
## [1.0.8-rc19] - unstable
|
||||
|
||||
[PostIt] Blogs UX
|
||||
|
||||
### Fixed
|
||||
|
||||
La saisie du billet est maintenant plutôt possible,
|
||||
en petit écran.
|
||||
|
||||
## [1.0.8-rc18] - unstable
|
||||
|
||||
* [PostIt] Page "About" avec lines vers le dépôt et les issues Forgejo
|
||||
|
||||
* Accès aux fichiers personnels PostIt rendu fonctionnel de bout en bout
|
||||
(arbre de stockage indexé par le login, pas par le `sub`), boutons
|
||||
d'action des listes réparés (binding `$parent`), revendication `name`
|
||||
enfin présente dans les access tokens (branchement du `ProfileService`),
|
||||
et négociation HTTP/3 côté client PostIt.
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
* [PostIt] Négociation HTTP/3 (QUIC) pour les appels API Yavsc :
|
||||
`DefaultRequestVersion = 3.0`, `DefaultVersionPolicy =
|
||||
RequestVersionOrLower` sur le `HttpClient` partagé de `YavscApiClient`,
|
||||
avec repli h2/h1.1 où QUIC n'est pas disponible (WASM fetch, Android/
|
||||
desktop sans libmsquic).
|
||||
* [Tests] `MyFilesPageHeadlessTests` : monte `MyFilesPage` via
|
||||
`NavigationPage` + `Window.Show()` et vérifie que chaque bouton de
|
||||
ligne (Télécharger / Supprimer) a sa `Command` liée, est armé
|
||||
(`CanExecute`) et déclenche l'appel API au clic.
|
||||
* [Tests] `Remoting.PasswordToken_carries_name_claim_equal_to_login` :
|
||||
mint un vrai JWT via le token endpoint IdentityServer (password grant,
|
||||
`openid profile`) et asserte que l'access token porte `name`=login et
|
||||
que `GetUserName()` résout vers ce login.
|
||||
* [Tests] `FileSystemApiTests.Get_fs_lists_files_stored_under_the_user_login` :
|
||||
découple `sub` et login, téléverse sous le login, et asserte que
|
||||
`GET /api/v1/fs` retourne le fichier (et que le répertoire nommé d'après
|
||||
le `sub` n'existe pas).
|
||||
|
||||
### Changed
|
||||
|
||||
* [Yavsc.Org] Branchement du `ProfileService` custom
|
||||
(`AddProfileService<ProfileService>()` après
|
||||
`AddAspNetIdentity<ApplicationUser>()`). La classe était du code mort :
|
||||
IdentityServer8 retombait sur son profile service par défaut, qui
|
||||
n'émet que les claims listés dans les `UserClaims` des API resources.
|
||||
`name` n'en faisait jamais partie, donc tout access token était sans
|
||||
`name` et `GetUserName()` résolvait à `null` sur les hosts ressources
|
||||
(Blogs/Api) — cause du 500 sur l'upload MyFiles et du
|
||||
`User.Identity.Name` injoignable. Le service émet désormais
|
||||
`name`=`user.UserName`, `email` et les rôles pour tout client dont
|
||||
les `AllowedScopes` contiennent `profile`, indépendamment du caller.
|
||||
* [Yavsc.Server] `ServiceExtensions` : `NameClaimType =
|
||||
JwtClaimTypes.Name`, `MapInboundClaims = false` (conserver les noms
|
||||
courts de claims tels qu'émis).
|
||||
* [Yavsc.Blogs] `FileSystemHelpers.EnsureDestinationDirectory` utilise
|
||||
`GetUserName()` (claim `name`) au lieu de `User.Identity.Name` pour
|
||||
la racine de stockage par utilisateur.
|
||||
* [Tests] `WebServerFixture` : `AccessTokenType = 0` (Jwt dans IS8 ;
|
||||
1 = Reference, l'inverse de Duende) ; `openid`+`profile` sur le client
|
||||
de test ; réensemencement des identity resources openid/profile dans
|
||||
`ReseedAuthTestData` (un `ResetAndMigrateDatabase` vide le store
|
||||
in-memory partagé et les laissait absentes → `invalid_scope`).
|
||||
* [Tests] `TestTokenIssuer.Issue` gagne un paramètre `name` optionnel
|
||||
(défaut = subject) pour refléter la forme production où
|
||||
`sub` (GUID) ≠ `name` (login).
|
||||
* [Tests] `BillingServiceTests` : `[Collection("Yavsc Server")]` pour
|
||||
sérialiser avec le build d'hôte qui mute les registres statiques
|
||||
`BillingService`/`Config` (race parallèle pré-existante).
|
||||
|
||||
### Fixed
|
||||
|
||||
* [Yavsc.Blogs] La liste `GET /api/v1/fs` cherchait sous
|
||||
`{Blog}/{sub-GUID}` (`User.GetUserId()`), alors que l'arbre de
|
||||
stockage perso est indexé par le login partout ailleurs
|
||||
(`EnsureDestinationDirectory` écrit sous `{Blog}/{name-claim}`,
|
||||
le endpoint de téléchargement et les avatars utilisent
|
||||
`user.UserName`). Une fois l'upload corrigé, les fichiers
|
||||
atterrissaient sous `{Blog}/{login}` tandis que la liste cherchait
|
||||
sous `{Blog}/{GUID}` — un arbre vide — d'où la page PostIt
|
||||
« Mes fichiers » qui ne listait rien. `GetDir` résout désormais
|
||||
l'utilisateur en base et liste sous `user.UserName`.
|
||||
`UploadedFile.OwnerId` reste `sub`, donc `EnrichWithFileIds`
|
||||
interpole toujours par `uid`.
|
||||
* [PostIt] Boutons d'action des lignes de liste grisés et inactifs au
|
||||
clic (Télécharger / Supprimer / Joindre sur MyFiles ; download/detach
|
||||
sur les listes de pièces jointes de `BillingQueryDetailsPage` et
|
||||
`EstimateEditionPage`). Le binding `$parent[vm:XxxViewModel].Cmd`
|
||||
cherchait un ancêtre CONTRÔLE de type `XxxViewModel` — un viewmodel,
|
||||
pas un contrôle — donc résolvait à `null` et le `Button` restait
|
||||
désactivé (grisé) et inerte au clic. Corrigé en
|
||||
`$parent[ContentPage].((vm:XxxViewModel)DataContext).Cmd` (syntaxe
|
||||
déjà en vigueur dans `CirclesPage` / `EstimateListPage`).
|
||||
* [PostIt] Les messages de la barre de statut (`StatusBar`) n'étaient
|
||||
pas sélectionnables/copiables : le texte était un `TextBlock` (non
|
||||
sélectionnable dans Avalonia). Remplacé par `SelectableTextBlock`.
|
||||
* [Yavsc.Blogs] La suppression d'un fichier (`DELETE api/v1/fs/{name}`)
|
||||
renvoyait un 40x alors que le fichier était bien supprimé du disque.
|
||||
`DeleteUserDirOrFile` (et `DeleteUserFile`) lisaient
|
||||
`FileInfo.Length` **après** `fi.Delete()` ; or `FileInfo.Length`
|
||||
lève `FileNotFoundException` une fois le fichier disparu — le `catch`
|
||||
du contrôleur transformait cette exception en 400, d'où le message
|
||||
perçu « le fichier n'existe pas sur le serveur » tandis que la
|
||||
suppression avait réussi. La taille est désormais capturée avant la
|
||||
suppression.
|
||||
* PostIt default settings
|
||||
|
||||
## [1.0.8-rc17] - unstable
|
||||
|
||||
Fichiers utilisateur et pièces jointes sur les devis/demandes (PostIt +
|
||||
API), système de génération `contrib` (vhosts nginx HTTP/3 edge +
|
||||
appsettings jq + QUIC logiciel Kestrel), et correction du bug de
|
||||
production où `IsPublished` disparaissait du payload de liste du blog API.
|
||||
|
||||
### Added
|
||||
|
||||
* [PostIt] Espace de stockage personnel (« MyFiles ») : page de gestion
|
||||
des fichiers de l'utilisateur, entrée depuis la page d'accueil.
|
||||
* [PostIt] Pièces jointes aux devis et aux demandes : attachement depuis
|
||||
le détail de demande et la page d'édition de devis.
|
||||
* [Yavsc.Api] Endpoints d'attachement de fichiers sur les devis
|
||||
(`EstimateApiController`) et les demandes front-office
|
||||
(`FrontOfficeApiController`).
|
||||
* [Yavsc.Api.Client] `UserFilesApiClient` + DTOs associés ; méthodes
|
||||
d'attachement sur `EstimateApiClient` et `FrontOfficeApiClient`.
|
||||
* [Yavsc.Server] Modèles `EstimateAttachedFile`, `QueryAttachedFile`,
|
||||
`BlogAttachedFile`, `AttachFileRequest` ; migration EF Core
|
||||
`UserFileAttachments`.
|
||||
* [contrib] Système de génération `Makefile` : vhosts nginx HTTP/3 edge
|
||||
(`template.vhost`, `QUIC_OPTS`), appsettings générés via jq
|
||||
(`template.appsettings-{org,api,blogs}.json`, échappement JSON-safe des
|
||||
secrets), QUIC logiciel Kestrel (`Http1AndHttp2AndHttp3`), et
|
||||
`README.nginx.md`.
|
||||
* [Tests] Projet `Yavsc.Server.Tests` : tests du service
|
||||
`BlogSpotService.Details` + `ServerServicesFixture` (SQLite `:memory:`
|
||||
par défaut, Npgsql opt-in via `YAVSC_SERVER_TEST_DB_PROVIDER`).
|
||||
* [Tests] `IBlogPostWireShapeTests` (forme du wire blog),
|
||||
`EstimateAttachmentsApiTests`, `FrontOfficeQueryAttachmentsApiTests`,
|
||||
`FileSystemApiTests` (pièces jointes et stockage perso).
|
||||
* [CI] Intégration Gitleaks / analyse de secrets (`.pre-commit-config.yaml`).
|
||||
|
||||
### Changed
|
||||
|
||||
* [contrib] `QUIC_OPTS` (`reuseport`) désormais activé uniquement en
|
||||
production sur le vhost Org. La preprod et la prod partageant la même
|
||||
IP, `reuseport` — légal une seule fois par `addr:port` (443/udp) —
|
||||
serait sinon en conflit entre preprod-Org et prod-Org et ferait refuser
|
||||
la config par nginx.
|
||||
* [Yavsc.Api.Test] `ApiWebServerFixture` : la connexion admin PostgreSQL
|
||||
provient de la variable d'environnement
|
||||
`YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION` ; plus de mot de passe en
|
||||
source.
|
||||
|
||||
### Fixed
|
||||
|
||||
* [Yavsc.Blogs] Bug de production : `IsPublished` était absent du payload
|
||||
de la liste du blog API. Le endpoint `GetBlogspot` retourne
|
||||
`IEnumerable<IBlogPost>` sérialisé par System.Text.Json d'après le type
|
||||
déclaré (l'interface), qui omet les propriétés du type concret —
|
||||
`IsPublished` vivait sur `BlogPost` / `BlogPostDto` mais pas sur
|
||||
`IBlogPost`. PostIt lisait donc `IsPublished == false` pour tout billet,
|
||||
y compris publiés (https://yavsc.pschneider.fr/BlogSpot/Details/13).
|
||||
Corrigé en déclarant `IsPublished` sur `IBlogPost`.
|
||||
* [Yavsc.Org] Accès au Swagger UI : les actions MVC scaffoldées de
|
||||
`CommentsController` (Index/Details/…) atterrissaient sur la même route
|
||||
`POST api/v1/blogcomments` que l'action d'API `Post`, provoquant une
|
||||
`SwaggerGeneratorException` (conflit method/path) au chargement de
|
||||
`/swagger`. Corrigé par `[ApiExplorerSettings(IgnoreApi = true)]` sur
|
||||
les actions scaffoldées.
|
||||
|
||||
## [1.0.8-rc16] - unstable
|
||||
|
||||
Flux de signature et de génération de devis : un devis peut désormais être
|
||||
signé par les deux parties (fournisseur et client) puis téléchargé en PDF
|
||||
ou TeX depuis PostIt, avec les signatures dessinées en vecteur.
|
||||
|
||||
### Added
|
||||
|
||||
* [PostIt] Pad de signature sur la page de validation d'un devis : on valide
|
||||
ou on refuse en joignant une signature (côté fournisseur ou client), capturée
|
||||
au format filaire PostIt (strokes) et envoyée sur l'acceptation.
|
||||
* [Yavsc.Api] Finalisation de `POST /api/v1/front/query/accept` et `.../reject` :
|
||||
autorisation selon le rôle (fournisseur/client/admin), signature optionnelle
|
||||
persistée sur l'estimate avec horodatage `ProviderValidationDate` /
|
||||
`ClientValidationDate` selon le côté signataire.
|
||||
* [Yavsc.Api] `QueryStatus.Accepted` éclaté en `ProAccepted` / `ClientAccepted`
|
||||
pour distinguer l'acceptation de chaque partie.
|
||||
* [Yavsc.Api] Endpoints de devis `GET /api/v1/front/query/{id}/estimate.tex` et
|
||||
`estimate.pdf` (clé par id de query). Le flux de génération TeX/PDF est
|
||||
rapatrié de Yavsc.Org vers Yavsc.Api (`TeXHelpers`, `PdfGenerationViewModel`,
|
||||
`BillViewComponent`, modèles Bill/Estimate, `_ViewImports` ; `Program.cs`
|
||||
câble `AddControllersWithViews` + `SiteSettings`).
|
||||
* [Yavsc.Api] Signatures du devis dessinées en TikZ vectoriel
|
||||
(`TeXHelpers.SignatureToTikz`) en bas du document, fournisseur et client
|
||||
côte à côte dans deux minipages ; plus de dépendance à un PNG de signature.
|
||||
* [Yavsc.Api.Client] `DownloadAsync` sur le client + `GetEstimateTexAsync` /
|
||||
`GetEstimatePdfAsync` ; boutons « Télécharger PDF » / « Télécharger TeX »
|
||||
dans PostIt sur la page de validation du devis et le détail de commande,
|
||||
avec un helper `StorageProvider` d'enregistrement de fichier.
|
||||
* [PostIt] La liste des devis se rafraîchit au retour de navigation (pop-back),
|
||||
de sorte qu'un devis validé quitte immédiatement la liste du signataire.
|
||||
* [PostIt] Renommage des titres de liste : « Devis en attente de signature
|
||||
fournisseur » / « Devis en attente de signature client ».
|
||||
* [Yavsc.Abstract] `SignatureType` déplacé dans `Yavsc.Abstract.Billing`.
|
||||
* [CLI] Option `-c configuration-file.json` au lancement.
|
||||
* [Tests] `EstimatePdfGenerationTests` : tests unitaires du pipeline
|
||||
signature → TikZ → PDF (devis signé des deux côtés), assertant que le PDF
|
||||
existe au chemin de sortie attendu et que seul le PDF persiste (aux/log
|
||||
nettoyés) ; skip propre si `lualatex` est absent.
|
||||
* [README] Dépendances système au runtime pour la génération PDF :
|
||||
`texlive-binaries` (lualatex), `texlive-luatex` (luaotfload),
|
||||
`texlive-pictures` (tikz), `texlive-fonts-extra` (bera).
|
||||
|
||||
### Changed
|
||||
|
||||
* [Yavsc.Api] `GetOngoingEstimatesAsProvider` filtre désormais sur
|
||||
`ProviderValidationDate` : un devis signé par le fournisseur quitte sa liste
|
||||
« en attente ».
|
||||
* [Yavsc.Api] La génération PDF compile via le `lualatex` système, le TeX fourni
|
||||
sur l'entrée standard (`/dev/stdin`), le PDF écrit directement dans le
|
||||
répertoire des factures (`Site.Bills`), les artefacts `.aux`/`.log` nettoyés
|
||||
afin que seul le `.pdf` persiste.
|
||||
|
||||
### Fixed
|
||||
|
||||
* [Yavsc.Api] `Emergency stop` à la génération PDF : `lualatex` sans argument
|
||||
fichier lit stdin en mode terminal et s'arrête après la première ligne d'un
|
||||
document multi-lignes. Corrigé en passant `/dev/stdin` comme nom de fichier
|
||||
d'entrée — stdin est alors traité comme un fichier régulier et le document
|
||||
multi-lignes compile. C'était la cause racine de l'échec en production, pas
|
||||
`bera` ni `luaotfload` (problèmes réels mais séparés, comblés côté paquets).
|
||||
* [Yavsc.Api] `I can't write on file 'estimate-1.log'` : le chemin relatif
|
||||
`Site.Bills` (`"bills"`) était imbriqué quand il servait à la fois de
|
||||
`-output-directory` et de répertoire de travail ; résolu en chemin absolu.
|
||||
* [Yavsc.Api] NRE dans `RenderViewToString` (RouteData / `ViewData.Model`
|
||||
nuls) : reconstruit sur le `ControllerContext` du contrôleur ; `IViewEngine`
|
||||
résolu via `IRazorViewEngine` (.NET 10 n'enregistre pas de `IViewEngine`
|
||||
unique).
|
||||
* [Yavsc.Api] `Layout = "null"` (chaîne) dans les modèles TeX cherchait
|
||||
`null.cshtml` ; corrigé en `Layout = null` (null C#).
|
||||
* [Yavsc.Api] `texi2pdf` (outil Texinfo, inadapté au LaTeX) remplacé par
|
||||
`lualatex`.
|
||||
* [Yavsc.Api] Les diagnostics lualatex étaient perdus (écrits dans le `.log`
|
||||
qu'on supprimait) ; stdout/stderr sont désormais capturés dans le message
|
||||
d'erreur, et le `.log` est conservé en cas d'échec.
|
||||
|
||||
## [1.0.8-rc15] - unstable
|
||||
|
||||
### Added
|
||||
|
||||
* [PostIt] Saisie et envoi de pieces jointes sur un billet de blog: le client monte les fichiers en `multipart/form-data` lors du `Save`, ajoute un lien Markdown par piece jointe a l'article, puis re-PUT le billet. Une collection `DraftAttachments` porte les fichiers en cours d'edition.
|
||||
* [PostIt] Apercu Markdown dans l'editeur de blogs (integration `MarkdownViewer.Core`) avec bascule edition/preview sur `BlogsPage`.
|
||||
* [PostIt] Gestion des devis: page de liste `EstimateListPage` (vues client et prestataire), page d'edition `EstimateEditionPage` avec lignes de devis (`EstimateLineItemViewModel`), et flux de validation d'un devis.
|
||||
* [PostIt] Flux des requetes en cours cote prestataire (`ProviderOngoingRequestsPage`) avec tri persistant.
|
||||
* [Yavsc.Api] Endpoints devis `GET /api/v1/estimate/asclient` et `GET /api/v1/estimate/asprovider` pour lister les devis en cours selon le role.
|
||||
* [Yavsc.Api] Endpoint `GET /api/v1/billing/provider/ongoing` et `POST /api/v1/billing/prosign/{billingCode}/{id}` pour le flux prestataire.
|
||||
* [Yavsc.Api.Client] `EstimateApiClient` + DTOs (`EstimateDto`, `EstimateLineDto`) pour le pipeline devis cote client lourd.
|
||||
* [Yavsc.Api.Client] Reprise de l'upload de fichiers blog dans `BlogApiClient` (`CreateMultipartContent`, `BlogUploadFile`) — la meme forme filaire que PostIt utilise en production.
|
||||
* [Yavsc.Abstract] `FileServerUrlHelpers` pour deriver les URLs publiques des fichiers utilisateur depuis l'autorite OIDC (alignement sur `UserFilesPath`).
|
||||
* [Yavsc.Server] `BlogSpotService.AttachFiles` : ecrit les pieces jointes sous la racine des fichiers utilisateur (`{UserFilesDirName}/{user}/blogs/{postId}/{fileName}`).
|
||||
* [Yavsc.Blogs] `BlogApiController` accepte `multipart/form-data` (champ `blog` + parts `file`) sur `POST` et `PUT /api/v1/blogspot`, en plus du payload JSON existant.
|
||||
* [PostIt] Variable d'environnement `POSTIT_SETTINGS_JSON` pour surcharger les parametres au lancement (utile en CI/test).
|
||||
* [Yavsc.Server] Migration EF `fileACL`: `CircleAuthorizationToFile` gagne `OwnerId` + `Access` et un graphe proprieetaire, base du controle d'acces fichier par cercle.
|
||||
* [Yavsc.Server] Migration EF `genericEstimate`: `Estimate.Query` passe de `RdvQuery` a `NominativeServiceCommand?` pour supporter des devis non lies a un RDV.
|
||||
* [Yavsc.Server] Migrations EF `NominativeServiceCommand` et `AddHairCutQueryLocationId` (localisation de la coupe).
|
||||
* [CI] Integration du scan de secrets Gitleaks dans le pipeline Forgejo + `.gitleaksignore`.
|
||||
* [Tests] Non-regressions: round-trip multipart blog (`BlogApiTests`, `CreateMultipartContentTests` forme + serveur), `BlogAttachmentLinkTests`, `FileServerUrlHelpersTests`, `EstimateApiControllerTests`, VMs `EstimateList`/`EstimateEdition`/`ProviderOngoingRequests`, `BillingControllerTests` (filtrage par utilisateur), `GetOpenIdConfiguration` contre l'hote de test.
|
||||
|
||||
### Changed
|
||||
|
||||
* [PostIt] Renommage `MainPage` -> `BlogsPage` (et `PushMainPageAsync` -> `PushBlogsPageAsync`) pour refletter le role de la page.
|
||||
* [PostIt] Refacto des `SiteSettings` : reorganisation des parametres, nouvelles valeurs par defaut, et indications plus claires dans les `appsettings`.
|
||||
* [Yavsc.Api] `BillingController` ne liste plus que les codes billing de l'utilisateur authentifie (filtre par `UserId`), au lieu de l'ensemble des requetes prestataire.
|
||||
* [Yavsc.Api] Mutualisation d'un `YavscMessageSender` partage dans l'hote API (au lieu d'une instance par controller).
|
||||
* [Yavsc.Org] La detection du doublon d'email a l'inscription utilise desormais le code erreur generic `DbException` 23505 (fournisseur-agnostique) au lieu de `PostgresException`, pour rester correct en test SQLite.
|
||||
* [PostIt] `YavscApiClient` tolere les corps vides (`204 No Content` sur `PUT /blogspot/{id}`) au lieu de lever un `JsonException`.
|
||||
* [CI] Le pipeline compile avant les tests, installe les dependances natives, et retire le `ItemGroup.Using Include=Xunit` superflu.
|
||||
|
||||
### Fixed
|
||||
|
||||
* [PostIt] Suppression d'un faux message d'erreur affiche apres un `PUT /blogspot/{id}` reussi (204 traite comme succes, plus comme une reponse vide invalide).
|
||||
* [PostIt] Correction de la creation des liens de pieces jointes (`TryAppendAttachmentLinks`) : segment proprietaire et chemin `blogs/{id}/{fileName}` alignes sur ce que `AttachFiles` ecrit reellement.
|
||||
* [Yavsc.Blogs] Correction de l'upload / de la persistance des pieces jointes (`FileSystemHelpers` + `BlogApiController` multipart).
|
||||
* [Tests] Stabilisation des suites: seeds billing avec metadonnees d'audit, `GetOpenIdConfiguration_returns_ok` contre l'hote de test, retraits d'assertions abusives, tests SQLite verts.
|
||||
* [PostIt] Nettoyage de fichiers temporaires accidentels et ignore des repertoires `tmp` dans le depot.
|
||||
|
||||
## [1.0.8-rc14] - unstable
|
||||
|
||||
### Added
|
||||
|
|
@ -329,6 +828,10 @@ the ACL now comes along with the article,
|
|||
the same user-visible switch without a schema change.
|
||||
|
||||
[Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD
|
||||
[1.0.8-rc16]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc15...1.0.8-rc16
|
||||
[1.0.8-rc17]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc16...1.0.8-rc17
|
||||
[1.0.8-rc18]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc17...1.0.8-rc18
|
||||
[1.0.8-rc15]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc14...1.0.8-rc15
|
||||
[1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1
|
||||
[1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7
|
||||
[1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6
|
||||
|
|
|
|||
|
|
@ -2,5 +2,16 @@
|
|||
<PropertyGroup>
|
||||
<RootNamespace>Yavsc</RootNamespace>
|
||||
<NoWarn>NU1701, NU1901, NU1902, NU1507</NoWarn>
|
||||
<!-- Disable the StaticWebAssets build cache (the *.dswa.cache.json
|
||||
files written under obj/.../ by DefineStaticWebAssets).
|
||||
ReadOrCreateCache opens them with File.OpenRead and no
|
||||
try/catch, so under parallel solution builds a sharing
|
||||
violation / TOCTOU race on the cache file escapes as
|
||||
MSB4018 and fails the whole build (seen on Yavsc.Blogs
|
||||
during the rc15 solution build). The cache is a pure
|
||||
build-speed optimization; turning it off has no effect on
|
||||
output correctness, only on incremental rebuild speed of
|
||||
the (wwwroot-light) web projects. -->
|
||||
<StaticWebAssetsCacheDefineStaticWebAssetsEnabled>false</StaticWebAssetsCacheDefineStaticWebAssetsEnabled>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -4,53 +4,71 @@
|
|||
<IdentityServer8Version>8.1.0-pazofrc007</IdentityServer8Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.9" />
|
||||
|
||||
<PackageVersion Include="DotMarkdown" Version="0.3.0" />
|
||||
<PackageVersion Include="Markdig" Version="1.3.2" />
|
||||
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.12" />
|
||||
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="$(IdentityServer8Version)" />
|
||||
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="$(IdentityServer8Version)" />
|
||||
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="$(IdentityServer8Version)" />
|
||||
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="$(IdentityServer8Version)" />
|
||||
<PackageVersion Include="HigginsSoft.IdentityServer8.Security" Version="$(IdentityServer8Version)" />
|
||||
<PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="$(IdentityServer8Version)" />
|
||||
|
||||
<PackageVersion Include="AsciiDocSharp" Version="0.1.0" />
|
||||
<PackageVersion Include="AsciiDocSharp.Converters.Html" Version="0.1.0" />
|
||||
|
||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
|
||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.0" />
|
||||
<PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" />
|
||||
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.13" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.13" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
|
||||
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
|
||||
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.2" />
|
||||
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.23.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Razor.Language" Version="6.0.36" />
|
||||
<PackageVersion Include="Microsoft.Extensions.CommandLineUtils" Version="1.1.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
|
||||
|
||||
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
|
||||
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
|
||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.23.0" />
|
||||
<PackageVersion Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageVersion Include="xunit.v3.common" Version="3.2.2" />
|
||||
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
|
||||
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
|
||||
|
||||
|
||||
<PackageVersion Include="Anthropic.SDK" Version="5.10.0" />
|
||||
<PackageVersion Include="Google.Apis.Calendar.v3" Version="1.75.0.4206" />
|
||||
<PackageVersion Include="Magick.NET-Q8-AnyCPU" Version="14.17.1" />
|
||||
<PackageVersion Include="MailKit" Version="4.18.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.StaticFiles" Version="2.3.13" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.12" />
|
||||
|
||||
|
||||
<PackageVersion Include="Microsoft.Testing.Platform.MSBuild" Version="2.4.1" />
|
||||
|
||||
<PackageVersion Include="MimeKit" Version="4.18.1" />
|
||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageVersion Include="PayPalMerchantSDK" Version="2.16.250" />
|
||||
<PackageVersion Include="pazof.rules" Version="1.1.3" />
|
||||
<PackageVersion Include="RazorEngine.NetCore" Version="3.1.1" />
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
25
Dockerfile
25
Dockerfile
|
|
@ -24,41 +24,24 @@
|
|||
# =====================================================================
|
||||
|
||||
# syntax=docker/dockerfile:1.7
|
||||
ARG BUILD_ENV_TAG=debian12-dotnet10-android36-v1
|
||||
ARG BUILD_ENV_TAG=debian13-dotnet10-android36-jdk21-v2
|
||||
FROM pazof/yavsc-build-env:${BUILD_ENV_TAG} AS build-env
|
||||
WORKDIR /src
|
||||
|
||||
# (1) Manifest + fichiers de projet pour le cache de restore
|
||||
COPY *.sln ./
|
||||
COPY Directory.*.props ./
|
||||
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/
|
||||
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/
|
||||
|
||||
# (2) Tout le code source
|
||||
COPY . .
|
||||
|
||||
# (4) Restore
|
||||
RUN dotnet restore
|
||||
|
||||
# (5) Build des trois services web principaux
|
||||
# Build des trois services web principaux
|
||||
RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly
|
||||
RUN dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly
|
||||
RUN dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly
|
||||
|
||||
# (6) Build APK Android (utilisé par le workflow docker-publish-android).
|
||||
# Build APK Android (utilisé par le workflow docker-publish-android).
|
||||
ARG ANDROID_TARGET_RID=android-arm64
|
||||
RUN dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj -c Release --no-restore -clp:ErrorsOnly -r ${ANDROID_TARGET_RID}
|
||||
|
||||
# (7) Publish des trois services web (les artifacts sont consommés par
|
||||
# Publish des trois services web (les artifacts sont consommés par
|
||||
# les stages publish-org / publish-api / publish-blogs ci-dessous).
|
||||
RUN mkdir -p /app/publish
|
||||
RUN dotnet publish src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-build -o /app/publish/Yavsc.Org
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
98
Makefile
98
Makefile
|
|
@ -5,19 +5,43 @@ DESTDIR=/tmp/yavsc
|
|||
APP_PATH=srv/www/yavsc
|
||||
APP_FULL_PATH=$(DESTDIR)/$(APP_PATH)
|
||||
|
||||
include .env
|
||||
|
||||
all:
|
||||
dotnet build --nologo
|
||||
|
||||
clean:
|
||||
dotnet clean -c $(CONFIG)
|
||||
|
||||
brutal_clean:
|
||||
@find -name "obj" -exec rm -rf {} \;
|
||||
@find -name "bin" -exec rm -rf {} \;
|
||||
|
||||
src/Yavsc/bin/output/wwwroot:
|
||||
dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish
|
||||
|
||||
test:
|
||||
ASPNETCORE_ENVIRONMENT=Development dotnet test
|
||||
ASPNETCORE_ENVIRONMENT=Development dotnet test --report-xunit \
|
||||
--report-xunit-html \
|
||||
--results-directory "test-reports"
|
||||
|
||||
test-postit:
|
||||
ASPNETCORE_ENVIRONMENT=Development dotnet test \
|
||||
--report-xunit \
|
||||
--report-xunit-html \
|
||||
--results-directory "test-reports" \
|
||||
--report-xunit-html-filename "postit-test-results.html" \
|
||||
--project src/PostIt/PostIt.Tests/PostIt.Tests.csproj
|
||||
|
||||
backend-tests: Blogs-backend-test Org-backend-test Api-backend-test
|
||||
Api-backend-test:
|
||||
Blogs-backend-test:
|
||||
Org-backend-test:
|
||||
%-backend-test:
|
||||
ASPNETCORE_ENVIRONMENT=Development dotnet test \
|
||||
--report-xunit \
|
||||
--report-xunit-html \
|
||||
--results-directory "test-reports" \
|
||||
--report-xunit-html-filename "$*-backend-test-results.html" \
|
||||
--project src/test/Yavsc.$*.Tests/Yavsc.$*.Tests.csproj
|
||||
|
||||
watch:
|
||||
dotnet watch -p:Configuration=$(CONFIG) --project src/Yavsc/Yavsc.csproj
|
||||
|
|
@ -48,70 +72,4 @@ docker-build:
|
|||
docker-run:
|
||||
docker run -d -p 5000:5000 --name yavsc yavsc
|
||||
|
||||
# Crée une branche release/<V> depuis main, met à jour les
|
||||
# `<Version>` des .csproj via dotnet-gitversion, et la
|
||||
# pousse sur origin.
|
||||
#
|
||||
# Usage : make release V=1.0.7-rc1
|
||||
#
|
||||
# Pré-requis : être sur main, working tree clean. La cible
|
||||
# vérifie les deux et refuse sinon — elle ne fait JAMAIS
|
||||
# de checkout automatique, c'est à l'opérateur de s'être
|
||||
# positionné sur la bonne branche au préalable (sinon le
|
||||
# bump pourrait partir sur une branche tierce par accident).
|
||||
#
|
||||
# Notes :
|
||||
# - Le nom de branche vient de l'argument V (ex: 1.0.7-rc1
|
||||
# donne release/1.0.7-rc1). C'est une étiquette d'intention,
|
||||
# pas la version assembly.
|
||||
# - La version dans les .csproj vient de GitVersion qui la
|
||||
# calcule depuis l'historique git (tag le plus proche +
|
||||
# nombre de commits). C'est la version assembly réelle.
|
||||
# - L'ordre (fetch → branche → bump → push) garantit qu'on
|
||||
# part d'un main synchro et qu'on ne pollue pas main avec
|
||||
# le bump (qui vit sur la branche release).
|
||||
# - Fail-fast si la branche existe déjà en local ou sur origin.
|
||||
release:
|
||||
@if [ -z "$(V)" ]; then \
|
||||
echo "Usage: make release V=<version>"; \
|
||||
echo " V : version semver (ex. 1.0.7-rc1) — sert à nommer la branche."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if [ -n "$$(git status --porcelain)" ]; then \
|
||||
echo "Working tree sale, refus de créer une branche release."; \
|
||||
git status --short; \
|
||||
exit 1; \
|
||||
fi
|
||||
@BRANCH="release/$(V)"; \
|
||||
if git show-ref --verify --quiet "refs/heads/$$BRANCH"; then \
|
||||
echo "La branche $$BRANCH existe déjà en local."; \
|
||||
echo " Pour la supprimer : git branch -D $$BRANCH"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
if git ls-remote --exit-code --heads origin "$$BRANCH" >/dev/null 2>&1; then \
|
||||
echo "La branche $$BRANCH existe déjà sur origin."; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo "==> Fetch + vérification synchro main"; \
|
||||
git fetch origin main; \
|
||||
if ! git merge-base --is-ancestor origin/main HEAD; then \
|
||||
echo "main a avancé plus loin que HEAD. Fais :"; \
|
||||
echo " git pull --ff-only origin main"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo "==> Création de $$BRANCH depuis main"; \
|
||||
git checkout -b "$$BRANCH"; \
|
||||
echo "==> dotnet-gitversion /updateprojectfiles"; \
|
||||
dotnet-gitversion /updateprojectfiles; \
|
||||
echo "==> Commit du bump"; \
|
||||
git add .; \
|
||||
if git diff --cached --quiet; then \
|
||||
echo "Pas de changements à committer (gitversion n'a produit aucune diff)."; \
|
||||
else \
|
||||
git commit -m "chore(release): bump version via gitversion for $(V)"; \
|
||||
fi; \
|
||||
echo "==> Push de $$BRANCH sur origin"; \
|
||||
git push -u origin "$$BRANCH"; \
|
||||
echo "==> Terminé. Branche $$BRANCH live sur origin."
|
||||
|
||||
.PHONY: test release
|
||||
.PHONY: test install docker-image docker-build docker-run
|
||||
|
|
|
|||
39
README.md
39
README.md
|
|
@ -61,9 +61,40 @@ make test
|
|||
|
||||
### les services et l'API
|
||||
|
||||
#### Dépendances système au runtime
|
||||
|
||||
La génération des devis et factures en PDF (`/api/v1/front/query/{id}/estimate.pdf`,
|
||||
`BillingController` → `BillViewComponent`) compile un source LaTeX via la commande
|
||||
système `lualatex` : le TeX est fourni sur l'entrée standard et le PDF est écrit
|
||||
directement dans le répertoire des factures (`Site.Bills`). `lualatex` doit donc
|
||||
être installé et opérationnel sur l'hôte qui exécute l'API — typiquement le service
|
||||
systemd `yavscApi`.
|
||||
|
||||
Sous Debian/Ubuntu :
|
||||
|
||||
```bash
|
||||
sudo apt install texlive-binaries texlive-luatex texlive-pictures texlive-fonts-extra
|
||||
```
|
||||
|
||||
- `texlive-binaries` fournit l'exécutable `lualatex` (collection basic).
|
||||
- `texlive-luatex` fournit les paquets de support chargés au démarrage de lualatex,
|
||||
notamment `luaotfload`. Sans lui, lualatex démarre mais s'arrête sur
|
||||
`module 'luaotfload-main' not found … Emergency stop` et ne produit aucun PDF.
|
||||
(Sur les Debian récents, `luaotfload` a migré de `texlive-latex-extra` vers
|
||||
`texlive-luatex` — avoir `texlive-latex-extra` seul ne suffit plus.)
|
||||
- `texlive-pictures` fournit `tikz`/`pgf`, utilisé pour dessiner les signatures
|
||||
du client et du fournisseur en bas du devis (vecteur, pas de PNG).
|
||||
- `texlive-fonts-extra` fournit la fonte `bera` chargée par le modèle de devis.
|
||||
Sans elle, lualatex s'arrête sur `! LaTeX Error: File 'bera.sty' not found`
|
||||
(`Emergency stop`, aucun PDF).
|
||||
|
||||
Sur un hôte sans `lualatex`, les endpoints PDF renvoient une erreur 500 « PDF
|
||||
generation failed » ; l'endpoint TeX (`estimate.tex`) reste utilisable et permet
|
||||
de compiler le document hors-ligne.
|
||||
|
||||
### La Prod
|
||||
|
||||
`cd srv/Yavsc` : `make pushInProd CONFIGURATION=Release`.
|
||||
`cd srv/Yavsc/contrib` : `make reinstall CONFIGURATION=Release APP_INSTALL_ENV=production`.
|
||||
|
||||
puis, pour une première installation
|
||||
`make install_service`.
|
||||
|
|
@ -201,6 +232,12 @@ L'impact de l'usage d'un nouveau nom d’environnement d'execution, à l'heure d
|
|||
* ~/Views/Home/Index.cshtml
|
||||
* ~/Views/Home/About.cshtml
|
||||
|
||||
# 👥 Crédits & Co-auteurs
|
||||
|
||||
Ce projet a été développé de manière hybride :
|
||||
- **Conception, architecture et revue de code :** Paul Schneider
|
||||
- **Assistance au code et optimisation :** IA générative (Gemini, Google, Claude (Anthropic), https://qoder.com/ (ex-Windsurf))
|
||||
|
||||
# Remerciements
|
||||
|
||||
Photo de <a href="https://unsplash.com/fr/@dewang?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Dewang Gupta</a>sur <a href="https://unsplash.com/fr/photos/homme-debout-pres-dun-arbre-Mu3T3DmvQQw?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a>
|
||||
|
|
|
|||
149
contrib/Makefile
149
contrib/Makefile
|
|
@ -1,23 +1,32 @@
|
|||
DESTDIR=
|
||||
DOTNET_FRAMEWORK=net10.0
|
||||
APP_PROJECT_NAMES=Org Blogs Api
|
||||
SLNDIR=..
|
||||
APP_INSTALL_ENV=preprod
|
||||
APP_INSTALL_PREFIX=preprod-
|
||||
|
||||
include $(SLNDIR)/.env
|
||||
include .env
|
||||
include .$(APP_INSTALL_ENV).env
|
||||
|
||||
generated/:
|
||||
@mkdir -p $@
|
||||
reinstall: copy-binaries
|
||||
@for project in $(APP_PROJECT_NAMES); do \
|
||||
echo "🚀 Starting $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
|
||||
sudo systemctl start $(APP_INSTALL_PREFIX)yavsc$${project} ; done
|
||||
@echo "💯 backend started"
|
||||
|
||||
generated/yavscOrg.service:
|
||||
generated/yavscBlogs.service:
|
||||
generated/yavscApi.service:
|
||||
reinstall-prod:
|
||||
make reinstall APP_INSTALL_PREFIX= APP_INSTALL_ENV=production
|
||||
|
||||
generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
|
||||
generated/$(APP_INSTALL_PREFIX)yavscOrg.service:
|
||||
generated/$(APP_INSTALL_PREFIX)yavscBlogs.service:
|
||||
generated/$(APP_INSTALL_PREFIX)yavscApi.service:
|
||||
|
||||
generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLNDIR)/.env
|
||||
@cat template.service | APP_NAME="$*" \
|
||||
DESTDIR="$(DESTDIR)" \
|
||||
HTTP_HOST="$(HTTP_HOST)" \
|
||||
HTTP_PORT="$*_$(HTTP_PORT)" \
|
||||
BASEAPPDIR="$(BASEAPPDIR)" \
|
||||
APP_SYSLOG_ID="yavsc$*" \
|
||||
HTTP_PORT="$($*_PORT)" \
|
||||
APPDIR="$(APPDIR)" \
|
||||
APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \
|
||||
POSTGRES_HOST="$(POSTGRES_HOST)" \
|
||||
POSTGRES_PORT="$(POSTGRES_PORT)" \
|
||||
POSTGRES_DB="$(POSTGRES_DB)" \
|
||||
|
|
@ -34,11 +43,83 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
|
|||
envsubst > $@
|
||||
@echo Created service file: $@
|
||||
|
||||
generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service
|
||||
|
||||
copy-services: copy-service-Org copy-service-Blogs copy-service-Api
|
||||
copy-service-Org: /etc/systemd/system/yavscOrg.service
|
||||
copy-service-Blogs: /etc/systemd/system/yavscBlogs.service
|
||||
copy-service-Api: /etc/systemd/system/yavscApi.service
|
||||
# --- Vhosts nginx (générés par envsubst, à l'image des unités systemd) ---
|
||||
# make generate-nginx -> generated/nginx/preprod-yavsc{Org,Blogs,Api}
|
||||
# make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= -> prod (yavsc/api/blogs.pschneider.fr)
|
||||
#
|
||||
# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert nginx
|
||||
# avec http_v3_module + UDP 443 ouvert. reuseport sur Org uniquement
|
||||
# (QUIC_OPTS). Les vhosts générés vont dans generated/nginx/ (gitignoré).
|
||||
NGINX_TEMPLATE := template.vhost
|
||||
NGINX_OUT_DIR := generated/nginx
|
||||
NGINX_HOSTS := Org Blogs Api
|
||||
DOMAIN_ROOT ?= pschneider.fr
|
||||
# Schéma backend Kestrel : https en prod (Kestrel écoute HTTPS, cert Let's
|
||||
# Encrypt), http en preprod (Kestrel écoute HTTP simple, cf. .preprod.env).
|
||||
BACKEND_SCHEME ?= $(if $(filter production,$(APP_INSTALL_ENV)),https,http)
|
||||
# sous-domaine par hôte : Org -> yavsc, Api -> api, Blogs -> blogs
|
||||
nginx_subdomain = $(if $(filter Org,$1),yavsc,$(if $(filter Api,$1),api,$(if $(filter Blogs,$1),blogs,$1)))
|
||||
# domaine complet : <prefix><subdomain>.<DOMAIN_ROOT>
|
||||
# prod Org -> yavsc.pschneider.fr
|
||||
# preprod Org -> preprod-yavsc.pschneider.fr
|
||||
nginx_domain = $(APP_INSTALL_PREFIX)$(call nginx_subdomain,$1).$(DOMAIN_ROOT)
|
||||
|
||||
# QUIC_OPTS : "reuseport" n'est légal qu'une seule fois par addr:port
|
||||
# (443/udp). preprod et prod partagent aujourd'hui la même IP, donc on ne
|
||||
# l'active qu'en production, sur Org uniquement. En preprod, QUIC_OPTS est
|
||||
# vide sur tous les vhosts (sinon nginx refuse la config : duplicate
|
||||
# reuseport sur le même socket 443/udp entre preprod-Org et prod-Org).
|
||||
$(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc%: $(NGINX_OUT_DIR) $(NGINX_TEMPLATE) .$(APP_INSTALL_ENV).env
|
||||
@cat $(NGINX_TEMPLATE) \
|
||||
| DOMAIN="$(call nginx_domain,$*)" \
|
||||
BACKEND_SCHEME="$(BACKEND_SCHEME)" \
|
||||
HTTP_HOST="$(HTTP_HOST)" \
|
||||
BACKEND_PORT="$($*_PORT)" \
|
||||
QUIC_OPTS="$(if $(filter production,$(APP_INSTALL_ENV)),$(if $(filter Org,$*), reuseport,),)" \
|
||||
envsubst '$$DOMAIN $$BACKEND_SCHEME $$HTTP_HOST $$BACKEND_PORT $$QUIC_OPTS' > $@
|
||||
@echo Created nginx vhost: $@
|
||||
|
||||
generate-nginx: $(addprefix $(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc,$(NGINX_HOSTS))
|
||||
|
||||
generate-nginx-prod:
|
||||
make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX=
|
||||
|
||||
$(NGINX_OUT_DIR):
|
||||
@mkdir -p $@
|
||||
|
||||
# --- appsettings (générés depuis template.appsettings-*.json) ---
|
||||
# make generate-appsettings -> generated/appsettings/appsettings-{org,api,blogs}.json
|
||||
#
|
||||
# Substitue $DOMAIN (racine du domaine, cf. DOMAIN_ROOT) dans toutes les
|
||||
# chaînes, et $SMTP_PASSWORD (secret du .env) dans Smtp.Password.
|
||||
# On utilise jq (et non envsubst) car envsubst n'échappe pas le JSON : un
|
||||
# secret contenant \ ou " casserait le JSON (le mot de passe SMTP actuel
|
||||
# contient un \). jq lit le secret depuis l'environnement (export) ->
|
||||
# échappement JSON correct, aucun shell-quoting ni exposition en ligne de cmd.
|
||||
# Les templates actuels sont issus de la preprod (préfixe preprod-, ports
|
||||
# 88-93) ; pour la prod il faudra un template prod (ports 82-87, sans préfixe).
|
||||
export ASPNETCORE_Smtp__Password
|
||||
APPSETTINGS_OUT_DIR := generated/appsettings
|
||||
APPSETTINGS_HOSTS := org api blogs
|
||||
|
||||
$(APPSETTINGS_OUT_DIR)/appsettings-%.json: $(APPSETTINGS_OUT_DIR) template.appsettings-%.json .$(APP_INSTALL_ENV).env
|
||||
@jq --arg domain "$(DOMAIN_ROOT)" \
|
||||
'walk(if type=="string" then gsub("\\$$DOMAIN"; $$domain) else . end) | if has("Smtp") then .Smtp.Password = env["ASPNETCORE_Smtp__Password"] else . end' \
|
||||
template.appsettings-$*.json > $@
|
||||
@echo Created appsettings: $@
|
||||
|
||||
generate-appsettings: $(addprefix $(APPSETTINGS_OUT_DIR)/appsettings-,$(addsuffix .json,$(APPSETTINGS_HOSTS)))
|
||||
|
||||
$(APPSETTINGS_OUT_DIR):
|
||||
@mkdir -p $@
|
||||
|
||||
install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$*
|
||||
|
||||
install-services: install-service-Org install-service-Blogs install-service-Api
|
||||
|
||||
copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-services
|
||||
@for project in $(APP_PROJECT_NAMES); \
|
||||
|
|
@ -52,39 +133,36 @@ copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-serv
|
|||
rm -f $${BINDIR}/appsettings-$${LCAPI}.json ; \
|
||||
sync ; \
|
||||
ls $${BINDIR}/appsettings* 2>&1 ; \
|
||||
sudo cp -a $${BINDIR}/* $(BASEAPPDIR) ; \
|
||||
sudo cp -a $${BINDIR}/* $(DESTDIR)$(APPDIR) ; \
|
||||
done
|
||||
@sudo chown -R $(USER_AND_GROUP) $(BASEAPPDIR)
|
||||
@sudo chown -R $(USER_AND_GROUP) $(DESTDIR)$(APPDIR)
|
||||
@sync
|
||||
|
||||
/etc/systemd/system/yavsc%.service: generated/yavsc%.service
|
||||
$(DESTDIR)/etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service: generated/$(APP_INSTALL_PREFIX)yavsc%.service
|
||||
sudo cp $^ $@
|
||||
sudo chown root:root $@
|
||||
|
||||
build_publish_%: clean_publish_dir_%
|
||||
@ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj
|
||||
|
||||
build_publish: build_publish_Org build_publish_Blogs build_publish_Api
|
||||
|
||||
clean_publish_dir_%:
|
||||
@rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish
|
||||
|
||||
install: build_publish copy-binaries copy-services
|
||||
install: copy-binaries copy-services
|
||||
@sudo systemctl daemon-reload
|
||||
@for project in $(APP_PROJECT_NAMES); \
|
||||
do \
|
||||
sudo systemctl enable yavsc$${project} ; \
|
||||
sudo systemctl start yavsc$${project} ; \
|
||||
echo "🖒 enable $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
|
||||
sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$${project} ; \
|
||||
echo "🚀 start $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
|
||||
sudo systemctl start $(APP_INSTALL_PREFIX)yavsc$${project} ; \
|
||||
done
|
||||
|
||||
reinstall: copy-binaries
|
||||
@sync
|
||||
@for project in $(APP_PROJECT_NAMES); do \
|
||||
sudo systemctl start yavsc$${project} ; done
|
||||
|
||||
stop-services:
|
||||
@for project in $(APP_PROJECT_NAMES); \
|
||||
do \
|
||||
sudo systemctl stop yavsc$${project} ; \
|
||||
echo "🔥 Stopping $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
|
||||
sudo systemctl stop $(APP_INSTALL_PREFIX)yavsc$${project} ; \
|
||||
done
|
||||
|
||||
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
|
||||
|
|
@ -93,8 +171,19 @@ $(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_
|
|||
|
||||
showConfig:
|
||||
@echo CONFIGURATION: $(CONFIGURATION)
|
||||
@echo BASEAPPDIR: $(BASEAPPDIR)
|
||||
@echo APPDIR: $(APPDIR)
|
||||
|
||||
showApiLogs:
|
||||
@sudo journalctl -u yavscApi.service -S "2 min ago" | tee yavscApi.log
|
||||
|
||||
showOrgLogs:
|
||||
@sudo journalctl -u yavscOrg.service -S "2 min ago" | tee yavscOrg.log
|
||||
|
||||
showBlogsLogs:
|
||||
@sudo journalctl -u yavscBlogs.service -S "2 min ago" | tee yavscBlogs.log
|
||||
|
||||
clean:
|
||||
@rm -rf generated
|
||||
|
||||
generated/:
|
||||
@mkdir -p $@
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
# Read me
|
||||
|
||||
## Note aux icones
|
||||
|
||||
㝉®🅬⛒⛑🩎🩺🞫🞮🞕🞖🞆🔴🔵🔲🖂🔧🔩🔐🔌💾💼💬💭👿👾🏷🎯🏹🌍🎎💩
|
||||
134
contrib/README.nginx.md
Normal file
134
contrib/README.nginx.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# Vhosts nginx — génération par `make generate-nginx`
|
||||
|
||||
Les vhosts nginx sont **générés** à l'image des unités de service systemd
|
||||
(`template.service` + `envsubst` → `generated/*.service`) : un template
|
||||
unique `contrib/template.vhost` est substitué par `envsubst` vers
|
||||
`contrib/generated/nginx/`, qui est **gitignoré**.
|
||||
|
||||
## Rôle de nginx : edge HTTP/3 (plan `contrib/quic.md`)
|
||||
|
||||
nginx termine HTTP/3 (QUIC) au edge sur 443/udp, en coexistence avec
|
||||
h1/h2 sur 443/tcp. Kestrel reste backend h1/h2 (`proxy_http_version 1.1`).
|
||||
Le « QUIC logiciel » Kestrel (`Protocols: Http1AndHttp2AndHttp3` dans les
|
||||
`template.appsettings-*.json`) est une capacité complémentaire — non
|
||||
joignable par les clients derrière nginx (nginx OSS = upstream h1.1
|
||||
uniquement), mais disponible si Kestrel est un jour exposé en direct.
|
||||
|
||||
Directives générées (par vhost) :
|
||||
```nginx
|
||||
listen 443 ssl; listen [::]:443 ssl; http2 on;
|
||||
listen 443 quic $QUIC_OPTS; listen [::]:443 quic $QUIC_OPTS; http3 on;
|
||||
add_header Alt-Svc 'h3=":443"; ma=86400' always;
|
||||
ssl_protocols TLSv1.3;
|
||||
```
|
||||
`$QUIC_OPTS = reuseport` sur **Org uniquement**, vide sur Api/Blogs (règle
|
||||
`contrib/quic.md` : `reuseport` une seule fois par addr:port, tous vhosts
|
||||
confondus).
|
||||
|
||||
## Prérequis côté host
|
||||
|
||||
1. **nginx avec `http_v3_module`** (≥ 1.25 ; la directive `http3 on;`
|
||||
exige ≥ 1.27.4 — sur une version 1.25–1.27.3, retirer `http3 on;`,
|
||||
`listen ... quic` suffit à activer h3) :
|
||||
```bash
|
||||
nginx -V 2>&1 | grep -o 'http_v3_module' # doit être non vide
|
||||
```
|
||||
Debian 13 « trixie » fournit `nginx` avec `http_v3_module`. Sinon,
|
||||
backports ou `nginx-full`.
|
||||
2. **UDP 443 ouvert** :
|
||||
```bash
|
||||
sudo ufw allow 443/udp
|
||||
sudo ufw status | grep '443/udp' # → ALLOW
|
||||
```
|
||||
3. **IPv6** : le template émet `listen [::]:443 ...` (et `[::]:80`). Si le
|
||||
host n'a pas d'IPv6, retirer/commenter ces lignes (sinon `nginx -t`
|
||||
échoue sur le bind IPv6).
|
||||
4. Certbot / certs Let's Encrypt déjà en place (chemins
|
||||
`/etc/letsencrypt/live/<domain>/{fullchain,privkey}.pem` référencés).
|
||||
5. (Optionnel, pour le QUIC logiciel Kestrel) `apt install libmsquic` —
|
||||
sinon Kestrel désactive h3 silencieusement (fallback h1/h2 gracieux).
|
||||
|
||||
## Génération
|
||||
|
||||
```bash
|
||||
cd contrib
|
||||
|
||||
# Preprod (défaut) -> generated/nginx/preprod-yavsc{Org,Blogs,Api}
|
||||
make generate-nginx
|
||||
|
||||
# Production -> generated/nginx/yavsc{Org,Blogs,Api}
|
||||
make generate-nginx-prod
|
||||
# ou manuellement :
|
||||
make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX=
|
||||
```
|
||||
|
||||
Ports backend et `HTTP_HOST` depuis `.preprod.env` / `.production.env`
|
||||
(prod Org=83, Api=87, Blogs=85 ; preprod 89/93/91). Schéma backend
|
||||
`https` en prod, `http` en preprod.
|
||||
|
||||
| Hôte | Sous-domaine | `reuseport` | Backend prod | Backend preprod |
|
||||
|-------|--------------|-------------|-------------------------|------------------------|
|
||||
| Org | yavsc | **oui** | `https://localhost:83` | `http://localhost:89` |
|
||||
| Api | api | non | `https://localhost:87` | `http://localhost:93` |
|
||||
| Blogs | blogs | non | `https://localhost:85` | `http://localhost:91` |
|
||||
|
||||
Domaines : `<prefix><subdomain>.pschneider.fr`
|
||||
(prod `yavsc.pschneider.fr` ; preprod `preprod-yavsc.pschneider.fr`).
|
||||
|
||||
## Déploiement sur le host
|
||||
|
||||
```bash
|
||||
sudo cp generated/nginx/yavscOrg /etc/nginx/sites-available/yavscOrg
|
||||
sudo ln -sf /etc/nginx/sites-available/yavscOrg /etc/nginx/sites-enabled/yavscOrg
|
||||
# (idem yavscApi, yavscBlogs)
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## Vérification (sur le host)
|
||||
|
||||
```bash
|
||||
# 1. Module h3 présent
|
||||
nginx -V 2>&1 | grep -o 'http_v3_module'
|
||||
|
||||
# 2. UDP 443 ouvert
|
||||
sudo ufw status | grep '443/udp'
|
||||
|
||||
# 3. En-tête Alt-Svc annoncé
|
||||
curl -sI https://yavsc.pschneider.fr/ | grep -i alt-svc # → h3=":443"
|
||||
|
||||
# 4. Requête HTTP/3 effective (curl compilé avec HTTP/3)
|
||||
curl -v --http3 https://yavsc.pschneider.fr/ 2>&1 | grep -i 'HTTP/3'
|
||||
|
||||
# 5. Fallback h2 et h1 toujours fonctionnels
|
||||
curl --http2 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n'
|
||||
curl --http1.1 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n'
|
||||
```
|
||||
Répéter 3-5 pour `api.pschneider.fr` et `blogs.pschneider.fr` (prod) et
|
||||
les trois domaines preprod. Sans curl HTTP/3, un navigateur : DevTools →
|
||||
Network → colonne « Protocole » → `h3` après mise à niveau.
|
||||
|
||||
## Comment ça marche (template + envsubst)
|
||||
|
||||
`contrib/template.vhost` contient les placeholders `$DOMAIN`,
|
||||
`$BACKEND_SCHEME`, `$HTTP_HOST`, `$BACKEND_PORT`, `$QUIC_OPTS`. La règle
|
||||
`generated/nginx/$(APP_INSTALL_PREFIX)yavsc%` du `Makefile` les substitue
|
||||
via `envsubst` avec une **allowlist** :
|
||||
|
||||
```makefile
|
||||
envsubst '$DOMAIN $BACKEND_SCHEME $HTTP_HOST $BACKEND_PORT $QUIC_OPTS'
|
||||
```
|
||||
|
||||
Cette allowlist limite la substitution aux 5 variables citées, de sorte
|
||||
que les variables nginx intrinsèques (`$host`, `$scheme`, `$remote_addr`,
|
||||
`$proxy_add_x_forwarded_for`, `$request_uri`) survivent intactes. Sans
|
||||
allowlist, `envsubst` détruirait ces variables nginx.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- `docker-compose` / `Dockerfile` : non touchés (déploiement bare-metal
|
||||
systemd + nginx).
|
||||
- Backend HTTP/2 vers Kestrel : nginx open-source ne supporte que
|
||||
HTTP/1.1 en upstream ; on garde `proxy_http_version 1.1`.
|
||||
- `ssl_protocols TLSv1.3` (suivant `contrib/quic.md`) désactive TLS 1.2
|
||||
côté TCP aussi. Pour conserver la compat TLS 1.2 sur le fallback TCP,
|
||||
relâcher en `ssl_protocols TLSv1.2 TLSv1.3;` (QUIC reste en 1.3).
|
||||
33
contrib/template.appsettings-api.json
Normal file
33
contrib/template.appsettings-api.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"Site": {
|
||||
"Authority": "https://preprod-yavsc.$DOMAIN",
|
||||
"Audience": ["api"],
|
||||
"CorsAllowedOrigins": [
|
||||
"https://preprod-blogs.$DOMAIN"
|
||||
],
|
||||
"Bills": "bills",
|
||||
"TempDir": "temp"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
"Url": "http://localhost:92"
|
||||
},
|
||||
"Https": {
|
||||
"Url": "https://localhost:93",
|
||||
"Certificate": {
|
||||
"Path": "/etc/letsencrypt/live/preprod-api.$DOMAIN/fullchain.pem",
|
||||
"KeyPath": "/etc/letsencrypt/live/preprod-api.$DOMAIN/privkey.pem"
|
||||
},
|
||||
"Protocols": "Http1AndHttp2AndHttp3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
contrib/template.appsettings-blogs.json
Normal file
40
contrib/template.appsettings-blogs.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"Site": {
|
||||
"Title": "Yavsc Blogs",
|
||||
"Description": "A collection of blogs about software development, technology, and programming.",
|
||||
"Keywords": "software development, technology, programming, coding, blogs",
|
||||
"Authority": "https://preprod-yavsc.$DOMAIN",
|
||||
"Audience": ["blogs"],
|
||||
"CorsAllowedOrigins": [
|
||||
"https://preprod-api.$DOMAIN"
|
||||
],
|
||||
"UserFilesRoot": "files"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information",
|
||||
"Microsoft.AspNetCore.Authentication": "Debug"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
"Url": "http://localhost:90"
|
||||
},
|
||||
"Https": {
|
||||
"Url": "https://localhost:91",
|
||||
"Certificate": {
|
||||
"Path": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/fullchain.pem",
|
||||
"KeyPath": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/privkey.pem"
|
||||
},
|
||||
"Protocols": "Http1AndHttp2AndHttp3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
74
contrib/template.appsettings-org.json
Normal file
74
contrib/template.appsettings-org.json
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Site": {
|
||||
"Title": "yavsc",
|
||||
"Slogan": "Yavsc!",
|
||||
"StyleSheet": "/css/default.css",
|
||||
"Authority": "https://preprod-yavsc.$DOMAIN",
|
||||
"Audience": [
|
||||
"blogs",
|
||||
"api"
|
||||
],
|
||||
"ExternalUrl": "https://preprod-yavsc.$DOMAIN",
|
||||
"ApiUrl": "https://preprod-api.$DOMAIN",
|
||||
"CorsAllowedOrigins": [
|
||||
"https://preprod-blogs.$DOMAIN",
|
||||
"https://preprod-api.$DOMAIN"
|
||||
],
|
||||
"Banner": "/images/banner-dewang.jpg",
|
||||
"Owner": {
|
||||
"Name": "Paul Schneider",
|
||||
"EMail": "paul@pschneider.fr"
|
||||
},
|
||||
"Admin": {
|
||||
"Name": "Paul Schneider",
|
||||
"EMail": "paul@pschneider.fr"
|
||||
},
|
||||
"Quota": 200000000,
|
||||
"UserFilesRoot": "files",
|
||||
"Avatars": "avatars",
|
||||
"Bills": "bills",
|
||||
"TempDir": "temp",
|
||||
"DataDir": "data",
|
||||
"GitRepository": "git"
|
||||
},
|
||||
"Smtp": {
|
||||
"Host": "mercure.pschneider.fr",
|
||||
"Port": 465,
|
||||
"SenderName": "Paul Schneider",
|
||||
"SenderEmail": "paul@pschneider.fr",
|
||||
"UserName": "paul",
|
||||
"Password": "$SMTP_PASSWORD"
|
||||
},
|
||||
"DataProtection": {
|
||||
"Keys": {
|
||||
"Dir": "DataProtection"
|
||||
},
|
||||
"RSAParamFile": "RSA-Params.json",
|
||||
"ExpiresInHours": 168
|
||||
},
|
||||
"Moderation": {
|
||||
"AutoApproveThreshold": 0.7,
|
||||
"AutoRejectThreshold": 0.9
|
||||
},
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
"Url": "http://localhost:88"
|
||||
},
|
||||
"Https": {
|
||||
"Url": "https://localhost:89",
|
||||
"Certificate": {
|
||||
"Path": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/fullchain.pem",
|
||||
"KeyPath": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/privkey.pem"
|
||||
},
|
||||
"Protocols": "Http1AndHttp2AndHttp3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,10 +10,10 @@ RestartSec=5s
|
|||
Type=simple
|
||||
User=yavsc
|
||||
Group=yavsc
|
||||
WorkingDirectory=$DESTDIR
|
||||
ExecStart=$DESTDIR/Yavsc.$APP_NAME
|
||||
WorkingDirectory=$APPDIR
|
||||
ExecStart=$APPDIR/Yavsc.$APP_NAME
|
||||
Restart=always
|
||||
Environment="HOME=$DESTDIR"
|
||||
Environment="HOME=$APPDIR"
|
||||
Environment="ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY"
|
||||
Environment="ANTHROPIC_MAX_TOKENS=$ANTHROPIC_MAX_TOKENS"
|
||||
Environment="ASPNETCORE_Environment=$ENV"
|
||||
|
|
@ -29,8 +29,6 @@ Environment="ASPNETCORE_Smtp__Password=\"$ASPNETCORE_Smtp__Password\""
|
|||
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=$APP_SYSLOG_ID
|
||||
|
||||
[Install]
|
||||
|
|
|
|||
81
contrib/template.vhost
Normal file
81
contrib/template.vhost
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Généré depuis contrib/template.vhost par `make generate-nginx` — NE PAS ÉDITER.
|
||||
# Vhost $DOMAIN — backend $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT
|
||||
#
|
||||
# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert un
|
||||
# nginx compilé avec http_v3_module (≥ 1.25 ; `http3 on` ≥ 1.27.4) + UDP 443
|
||||
# ouvert. Kestrel reste backend h1/h2 (mais configure aussi h3 côté logiciel,
|
||||
# cf. template.appsettings-*.json — capacité, non joignable derrière nginx).
|
||||
#
|
||||
# Variables substituées par envsubst (allowlist) :
|
||||
# DOMAIN BACKEND_SCHEME HTTP_HOST BACKEND_PORT QUIC_OPTS
|
||||
# $QUIC_OPTS = "reuseport" sur un seul vhost (Org) ET en production
|
||||
# uniquement ; vide en preprod et sur les autres hôtes. Règle : reuseport
|
||||
# une seule fois par addr:port (443/udp) — or preprod et prod partagent la
|
||||
# même IP, donc seule la prod (Org) l'active.
|
||||
# Les variables nginx intrinsèques ($host, $scheme, $remote_addr, …) sont
|
||||
# préservées (hors allowlist).
|
||||
|
||||
# Port 80 : redirection vers HTTPS. (Certbot/nginx-plugin gère les challenges
|
||||
# ACME ; ne pas servir l'app en HTTP clair.)
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name $DOMAIN;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
# 1. TCP : HTTPS classique + HTTP/2
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
|
||||
# 2. UDP : HTTP/3 (QUIC). reuseport sur UN seul vhost (Org via QUIC_OPTS).
|
||||
listen 443 quic$QUIC_OPTS;
|
||||
listen [::]:443 quic$QUIC_OPTS;
|
||||
http3 on;
|
||||
|
||||
server_name $DOMAIN;
|
||||
|
||||
# 3. Certificats Let's Encrypt (Certbot).
|
||||
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem; # managed by Certbot
|
||||
# QUIC exige TLS 1.3. (TLS 1.2 conservable pour le fallback TCP :
|
||||
# `ssl_protocols TLSv1.2 TLSv1.3;` — QUIC reste en 1.3.)
|
||||
ssl_protocols TLSv1.3;
|
||||
|
||||
# 4. Annonce h3 au navigateur sur 443/udp. `always` = émis même en erreur.
|
||||
add_header Alt-Svc 'h3=":443"; ma=86400' always;
|
||||
|
||||
location / {
|
||||
proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 100s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# SignalR (ChatHub) : WebSocket exige Upgrade/Connection et un timeout
|
||||
# long, sinon la connexion tombe au premier silence ou l'upgrade échoue.
|
||||
# Connection est fixé à "upgrade" (pas de map $connection_upgrade, qui
|
||||
# exigerait une directive map {} globale hors de ce vhost) : SignalR
|
||||
# négocie toujours un upgrade sur ce chemin, et la négociation initiale
|
||||
# (POST /negotiate, sans en-tête Upgrade) n'est pas affectée par cet
|
||||
# en-tête côté Kestrel.
|
||||
location /chathub {
|
||||
proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
include server-status.conf;
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
|
||||
|
||||
server {
|
||||
if ($host = yavsc.pschneider.fr) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
|
||||
|
||||
listen 80;
|
||||
server_name yavsc.pschneider.fr;
|
||||
location / {
|
||||
root /srv/www/yavsc/wwwroot;
|
||||
dav_methods PUT;
|
||||
|
||||
proxy_pass http://127.0.0.1:3003;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection keep-alive;
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name yavsc.pschneider.fr;
|
||||
ssl_certificate /etc/letsencrypt/live/yavsc.pschneider.fr-0001/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/yavsc.pschneider.fr-0001/privkey.pem; # managed by Certbot
|
||||
# Configure the SignalR Endpoint
|
||||
location / {
|
||||
# App server url
|
||||
proxy_pass http://localhost:3003;
|
||||
|
||||
proxy_cache off;
|
||||
# WebSockets were implemented after http/1.0
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Configuration for ServerSentEvents
|
||||
proxy_buffering off;
|
||||
|
||||
# Configuration for LongPolling or if your KeepAliveInterval is longer than 60 seconds
|
||||
proxy_read_timeout 100s;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -126,6 +126,20 @@ le DI est construit. Ordre, dans cet ordre :
|
|||
> à cette table, *ne pas* ajouter de registration pour
|
||||
> `Settings` ailleurs que la ligne `AddSingleton(settings)`.
|
||||
|
||||
## Défilement de la page des billets
|
||||
|
||||
La page `BlogsPage` utilise une grille à hauteur contrainte : l'en-tête
|
||||
et les commandes restent visibles, tandis que la liste des billets et
|
||||
l'éditeur se partagent l'espace disponible. Un `GridSplitter` permet
|
||||
de modifier cette répartition. La liste et l'article défilent séparément.
|
||||
|
||||
Dans `MarkdownEditorControl`, AvaloniaEdit gère son propre défilement ;
|
||||
l'aperçu Markdown est placé dans un `ScrollViewer`. Ne pas limiter
|
||||
la hauteur du `MarkdownViewer` lui-même : son contenu doit pouvoir
|
||||
dépasser le viewport pour que les barres automatiques s'activent.
|
||||
Ne pas placer cette grille dans un `StackPanel` vertical ou un
|
||||
`ScrollViewer` de page, qui lui donnerait une hauteur de mesure illimitée.
|
||||
|
||||
## Navigation
|
||||
|
||||
Le host de navigation est un `NavigationPage x:Name="NavRoot"`
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ services:
|
|||
dockerfile: Dockerfile
|
||||
target: web-runtime
|
||||
args:
|
||||
BUILD_ENV_TAG: debian12-dotnet10-android36-v1
|
||||
BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2
|
||||
secrets:
|
||||
- source: yavsc_appsettings
|
||||
target: yavsc_appsettings
|
||||
|
|
@ -53,9 +53,7 @@ services:
|
|||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- yavsc-internal
|
||||
- yavsc-public
|
||||
network_mode: host
|
||||
# volumes:
|
||||
# # Indispensable pour que Yavsc.Org (IdentityServer8) démarre.
|
||||
# # Sans ce montage, 'docker compose up' échoue côté web avec
|
||||
|
|
@ -73,7 +71,7 @@ services:
|
|||
dockerfile: Dockerfile
|
||||
target: api-runtime
|
||||
args:
|
||||
BUILD_ENV_TAG: debian12-dotnet10-android36-v1
|
||||
BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2
|
||||
secrets:
|
||||
- source: yavsc_appsettings
|
||||
target: yavsc_appsettings
|
||||
|
|
@ -90,9 +88,7 @@ services:
|
|||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- yavsc-internal
|
||||
- yavsc-public
|
||||
network_mode: host
|
||||
# volumes:
|
||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
|
||||
|
|
@ -105,7 +101,7 @@ services:
|
|||
dockerfile: Dockerfile
|
||||
target: blogs-runtime
|
||||
args:
|
||||
BUILD_ENV_TAG: debian12-dotnet10-android36-v1
|
||||
BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2
|
||||
secrets:
|
||||
- source: yavsc_appsettings
|
||||
target: yavsc_appsettings
|
||||
|
|
@ -122,9 +118,7 @@ services:
|
|||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- yavsc-internal
|
||||
- yavsc-public
|
||||
network_mode: host
|
||||
# volumes:
|
||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {}
|
||||
"tools": {
|
||||
"picket": {
|
||||
"version": "0.2.12",
|
||||
"commands": [
|
||||
"picket"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
5
global.json
Normal file
5
global.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"test": {
|
||||
"runner": "Microsoft.Testing.Platform"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +1,39 @@
|
|||
<Project>
|
||||
<Import
|
||||
Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
|
||||
<!-- PostIt-product-specific versions -->
|
||||
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AvaloniaVersionBase>12.1.1</AvaloniaVersionBase>
|
||||
<AvaloniaVersionBase>12.1.3</AvaloniaVersionBase>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
|
||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Headless" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Headless" Version="12.1.3" />
|
||||
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="LibVLCSharp.Avalonia" Version="3.10.1" />
|
||||
<PackageVersion Include="Live.Avalonia" Version="1.4.2" />
|
||||
<PackageVersion Include="Mapsui" Version="5.1.0" />
|
||||
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
|
||||
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.19.0" />
|
||||
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.6-47" />
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.21.0" />
|
||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
|
||||
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.12" />
|
||||
<PackageVersion Include="Mono.Options" Version="6.12.0.148" />
|
||||
<PackageVersion Include="PvRecorder" Version="1.2.13" />
|
||||
<PackageVersion Include="Silk.NET.OpenAL" Version="2.23.0" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.10.0.1" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.10.0.1" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0.3" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.11.0.1" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.11.0.1" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Process" Version="2.11.0.1" />
|
||||
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
|
||||
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
|
||||
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Content.PM;
|
||||
using Android.Views;
|
||||
using AndroidX.Core.Provider;
|
||||
using AndroidX.Emoji2.Text;
|
||||
using Avalonia.Android;
|
||||
|
|
@ -15,7 +16,9 @@ namespace PostIt.Android;
|
|||
Theme = "@style/MyTheme.NoActionBar",
|
||||
Icon = "@drawable/icon",
|
||||
MainLauncher = true,
|
||||
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
|
||||
WindowSoftInputMode = SoftInput.AdjustResize,
|
||||
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)
|
||||
]
|
||||
public class MainActivity : AvaloniaMainActivity
|
||||
{
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@
|
|||
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<AndroidResource Include="Icon.png">
|
||||
|
|
@ -27,4 +27,14 @@
|
|||
<ItemGroup>
|
||||
<ProjectReference Include="..\PostIt\PostIt.csproj" />
|
||||
</ItemGroup>
|
||||
<!-- This is an Avalonia app, not a MAUI app: exclude the .NET MAUI VS Code
|
||||
extension's injected HotReload agent (references Microsoft.Maui.*).
|
||||
The agent file is added via CustomAfterMicrosoftCommonTargets, i.e. after
|
||||
this file's item groups, so the removal must run in a target. -->
|
||||
<Target Name="ExcludeMauiDeployHotReloadAgent" BeforeTargets="CoreCompile"
|
||||
Condition="'$(MauiDeployHotReloadAgentSource)' != ''">
|
||||
<ItemGroup>
|
||||
<Compile Remove="$(MauiDeployHotReloadAgentSource)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<application android:label="PostIt" android:icon="@drawable/Icon">
|
||||
<application
|
||||
android:label="PostIt"
|
||||
android:icon="@drawable/Icon"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:hardwareAccelerated="true">
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">10.0.2.2</domain> <!-- Debug address -->
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
|
|
@ -6,8 +6,8 @@
|
|||
<Nullable>enable</Nullable>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Browser" />
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
<Nullable>enable</Nullable>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using Avalonia;
|
||||
using PostIt.Services;
|
||||
using PostIt.CLIOptions;
|
||||
|
||||
namespace PostIt.Desktop;
|
||||
|
||||
|
|
@ -12,6 +13,8 @@ sealed class Program
|
|||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
CLIOptionHandler.HandleArgs(args);
|
||||
|
||||
// Short-circuit 2nd-instance launches (OS handing us the
|
||||
// postit://callback URL) BEFORE Avalonia spins up a window.
|
||||
// If we let Avalonia initialise, the new MainWindow flashes
|
||||
|
|
|
|||
|
|
@ -162,6 +162,12 @@ public class ActivitiesPageViewModelTests
|
|||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync(method, path, (object?)null, ct);
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -352,6 +352,12 @@ public class BillingCommandPageViewModelTests
|
|||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync(method, path, (object?)null, ct);
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,10 +45,8 @@ public class BillingQueriesPageViewModelTests
|
|||
Assert.Equal(2, vm.Queries.Count);
|
||||
Assert.All(vm.Queries, q => Assert.DoesNotContain("Rejected", q.StatusLabel, StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains("lecture seule", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.False(vm.CanOpenDetails);
|
||||
|
||||
vm.SelectedQuery = vm.Queries[0];
|
||||
Assert.False(vm.OpenSelectedQueryCommand.CanExecute(null));
|
||||
Assert.True(vm.Queries.Count > 0);
|
||||
}
|
||||
|
||||
private sealed class StubBillingApi : IYavscApiClient
|
||||
|
|
@ -129,6 +127,12 @@ public class BillingQueriesPageViewModelTests
|
|||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync(method, path, (object?)null, ct);
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,4 +62,25 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
|
|||
});
|
||||
return Task.FromResult(default(T)!);
|
||||
}
|
||||
|
||||
/// <summary>Multipart overload: BlogApiClient routes
|
||||
/// Create/UpdatePostAsync with attachments through here.
|
||||
/// Without this override the call would fall through to the
|
||||
/// base <see cref="YavscApiClient"/> and attempt a real HTTP
|
||||
/// round-trip against the stub authority. The body is a
|
||||
/// factory, so we record the delegate itself; tests that
|
||||
/// care about the multipart shape invoke it.</summary>
|
||||
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)!);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
116
src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs
Normal file
116
src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
using Yavsc.Blogspot;
|
||||
using Yavsc.Api.Client;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the markdown link that <see cref="BlogsViewModel.SaveAsync"/>
|
||||
/// appends to the article after a successful attachment upload.
|
||||
///
|
||||
/// <para>The link must be an absolute URL derived from the OIDC
|
||||
/// authority via <c>FileServerUrlHelpers.GetUserFilesUri</c>
|
||||
/// (Yavsc.Abstract): the <c>/files</c> file server is hosted by
|
||||
/// Yavsc.Org — the IdP — not by the blogs API host, so a link
|
||||
/// built from <c>BlogsApiUrl</c> (or left relative) 404s when the
|
||||
/// reader opens the post. The regression this guards: a generated
|
||||
/// link of the shape <c>/files/{user}/blogs/{id}/{name}</c>
|
||||
/// (relative, authority-less) instead of
|
||||
/// <c>https://{authority}/files/{user}/blogs/{id}/{name}</c>.</para>
|
||||
/// </summary>
|
||||
public class BlogAttachmentLinkTests
|
||||
{
|
||||
[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";
|
||||
|
||||
var recorder = new CallRecorder();
|
||||
var api = new RecordingYavscApiClient(recorder);
|
||||
var blog = new BlogApiClient(api, "https://blogs.example.test/api/v1/");
|
||||
var settings = new Settings
|
||||
{
|
||||
Authentication = new AuthenticationSettings
|
||||
{
|
||||
Authority = authority,
|
||||
ClientId = "stub",
|
||||
Scopes = new[] { "openid" },
|
||||
},
|
||||
};
|
||||
var vm = new BlogsViewModel(blog, settings);
|
||||
|
||||
// Update branch: an existing post is selected, the user
|
||||
// edits the buffers and attaches a file.
|
||||
vm.SelectedPost = new BlogPostDto
|
||||
{
|
||||
Id = 42,
|
||||
AuthorId = "tester",
|
||||
Title = "Avant",
|
||||
Article = "Contenu initial.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow,
|
||||
};
|
||||
vm.DraftTitle = "Après";
|
||||
vm.DraftArticle = "Contenu modifié.";
|
||||
vm.DraftAttachments.Add(new BlogUploadFile(
|
||||
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();
|
||||
|
||||
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 sentStringContent = Assert.IsType<StringContent>(multipartContent.First());
|
||||
|
||||
var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
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/", 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);
|
||||
}
|
||||
}
|
||||
284
src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs
Normal file
284
src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
using System.Net.Http;
|
||||
using PostIt.Models;
|
||||
using PostIt.ViewModels;
|
||||
using Yavsc;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class EstimateEditionPageViewModelTests
|
||||
{
|
||||
private static BillingQuerySummaryDto SampleQuery() => new()
|
||||
{
|
||||
Id = 42,
|
||||
BillingCode = "Brush",
|
||||
ActivityCode = "hair",
|
||||
PerformerId = "perf-1",
|
||||
ClientId = "cli-1",
|
||||
Status = QueryStatus.InProgress,
|
||||
Description = "Coupe simple",
|
||||
EventDate = new DateTime(2026, 9, 12, 10, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null)
|
||||
{
|
||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
||||
var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/");
|
||||
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client, fsClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_prefills_description_and_adds_a_first_line()
|
||||
{
|
||||
var api = new StubEstimateApi();
|
||||
var vm = CreateViewModel(api);
|
||||
|
||||
Assert.Equal("Coupe simple", vm.EstimateDescription);
|
||||
Assert.Single(vm.Lines);
|
||||
Assert.Same(vm.Lines[0], vm.SelectedLine);
|
||||
Assert.Contains("#42", vm.ContextLabel);
|
||||
Assert.Contains("cli-1", vm.ContextLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddLine_appends_and_selects_the_new_line()
|
||||
{
|
||||
var api = new StubEstimateApi();
|
||||
var vm = CreateViewModel(api);
|
||||
|
||||
vm.AddLineCommand.Execute(null);
|
||||
|
||||
Assert.Equal(2, vm.Lines.Count);
|
||||
Assert.Same(vm.Lines[1], vm.SelectedLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveLine_removes_the_selected_line()
|
||||
{
|
||||
var api = new StubEstimateApi();
|
||||
var vm = CreateViewModel(api);
|
||||
var first = vm.Lines[0];
|
||||
|
||||
vm.RemoveLineCommand.Execute(null);
|
||||
|
||||
Assert.Empty(vm.Lines);
|
||||
Assert.Null(vm.SelectedLine);
|
||||
Assert.False(vm.RemoveLineCommand.CanExecute(null));
|
||||
Assert.DoesNotContain(first, vm.Lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Total_sums_line_totals_and_tracks_edits()
|
||||
{
|
||||
var api = new StubEstimateApi();
|
||||
var vm = CreateViewModel(api);
|
||||
|
||||
vm.Lines[0].Count = 2;
|
||||
vm.Lines[0].UnitaryCost = 15.5m;
|
||||
|
||||
Assert.Equal(31m, vm.Total);
|
||||
Assert.Equal($"{31m:0.00} EUR", vm.TotalLabel);
|
||||
|
||||
vm.AddLineCommand.Execute(null);
|
||||
vm.Lines[1].Count = 1;
|
||||
vm.Lines[1].UnitaryCost = 9m;
|
||||
|
||||
Assert.Equal(40m, vm.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_without_title_warns_and_does_not_post()
|
||||
{
|
||||
var api = new StubEstimateApi();
|
||||
var vm = CreateViewModel(api);
|
||||
vm.Lines[0].Name = "Coupe";
|
||||
vm.Lines[0].Description = "Coupe simple";
|
||||
vm.Lines[0].UnitaryCost = 25m;
|
||||
|
||||
await vm.SendCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Null(api.LastBody);
|
||||
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
|
||||
Assert.Contains("titre", vm.ActionStatus.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_without_any_line_warns_and_does_not_post()
|
||||
{
|
||||
var api = new StubEstimateApi();
|
||||
var vm = CreateViewModel(api);
|
||||
vm.EstimateTitle = "Devis coupe";
|
||||
vm.Lines.Clear();
|
||||
|
||||
await vm.SendCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Null(api.LastBody);
|
||||
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
|
||||
Assert.Contains("ligne", vm.ActionStatus.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_with_a_blank_line_name_warns_and_does_not_post()
|
||||
{
|
||||
var api = new StubEstimateApi();
|
||||
var vm = CreateViewModel(api);
|
||||
vm.EstimateTitle = "Devis coupe";
|
||||
vm.Lines[0].Description = "Oubli du nom";
|
||||
|
||||
await vm.SendCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Null(api.LastBody);
|
||||
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
|
||||
Assert.Contains("nom", vm.ActionStatus.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_posts_the_estimate_payload_to_the_estimate_route()
|
||||
{
|
||||
var api = new StubEstimateApi();
|
||||
var vm = CreateViewModel(api);
|
||||
vm.EstimateTitle = " Devis coupe ";
|
||||
vm.Lines[0].Name = "Coupe";
|
||||
vm.Lines[0].Description = "Coupe simple";
|
||||
vm.Lines[0].Count = 2.4m;
|
||||
vm.Lines[0].UnitaryCost = 25m;
|
||||
|
||||
await vm.SendCommand.ExecuteAsync(null);
|
||||
|
||||
var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate");
|
||||
var payload = Assert.IsType<EstimateDto>(postCall.Body);
|
||||
Assert.Equal(42, payload.CommandId);
|
||||
Assert.Equal("cli-1", payload.ClientId);
|
||||
Assert.Equal("Brush", payload.CommandType);
|
||||
Assert.Equal("Devis coupe", payload.Title);
|
||||
Assert.Equal("Coupe simple", payload.Description);
|
||||
Assert.Empty(payload.AttachedFiles);
|
||||
Assert.Empty(payload.AttachedGraphics);
|
||||
|
||||
var line = Assert.Single(payload.Bill);
|
||||
Assert.Equal("Coupe", line.Name);
|
||||
Assert.Equal(2, line.Count);
|
||||
Assert.Equal(25m, line.UnitaryCost);
|
||||
Assert.Equal("EUR", line.Currency);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_marks_the_page_as_sent_and_disables_resend()
|
||||
{
|
||||
var api = new StubEstimateApi();
|
||||
var vm = CreateViewModel(api);
|
||||
vm.EstimateTitle = "Devis coupe";
|
||||
vm.Lines[0].Name = "Coupe";
|
||||
vm.Lines[0].Description = "Coupe simple";
|
||||
vm.Lines[0].UnitaryCost = 25m;
|
||||
|
||||
await vm.SendCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.True(vm.HasSent);
|
||||
Assert.False(vm.SendCommand.CanExecute(null));
|
||||
Assert.Equal("Devis envoyé", vm.SendLabel);
|
||||
Assert.Equal(StatusSeverity.Info, vm.ActionStatus.Severity);
|
||||
Assert.Contains("#7", vm.ActionStatus.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_surfaces_server_errors_as_error_status()
|
||||
{
|
||||
var api = new StubEstimateApi { Failure = new HttpRequestException("boom", null, System.Net.HttpStatusCode.InternalServerError) };
|
||||
var vm = CreateViewModel(api);
|
||||
vm.EstimateTitle = "Devis coupe";
|
||||
vm.Lines[0].Name = "Coupe";
|
||||
vm.Lines[0].Description = "Coupe simple";
|
||||
|
||||
await vm.SendCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.False(vm.HasSent);
|
||||
Assert.Equal(StatusSeverity.Error, vm.ActionStatus.Severity);
|
||||
Assert.True(vm.SendCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_accepts_negative_amounts_for_discount_lines()
|
||||
{
|
||||
var api = new StubEstimateApi();
|
||||
var vm = CreateViewModel(api);
|
||||
vm.EstimateTitle = "Devis avec remise";
|
||||
vm.Lines[0].Name = "Coupe";
|
||||
vm.Lines[0].Description = "Coupe simple";
|
||||
vm.Lines[0].UnitaryCost = 25m;
|
||||
|
||||
vm.AddLineCommand.Execute(null);
|
||||
vm.Lines[1].Name = "Remise fidélité";
|
||||
vm.Lines[1].Description = "Remise client régulier";
|
||||
vm.Lines[1].UnitaryCost = -5m;
|
||||
|
||||
Assert.Equal(20m, vm.Total);
|
||||
|
||||
await vm.SendCommand.ExecuteAsync(null);
|
||||
|
||||
var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate");
|
||||
var payload = Assert.IsType<EstimateDto>(postCall.Body);
|
||||
Assert.Equal(2, payload.Bill.Count);
|
||||
Assert.Equal(-5m, payload.Bill[1].UnitaryCost);
|
||||
Assert.True(vm.HasSent);
|
||||
}
|
||||
|
||||
private sealed class StubEstimateApi : IYavscApiClient
|
||||
{
|
||||
public HttpClient Http { get; } = new();
|
||||
public string? LastPath { get; private set; }
|
||||
public HttpMethod? LastMethod { get; private set; }
|
||||
public object? LastBody { get; private set; }
|
||||
public Exception? Failure { get; init; }
|
||||
|
||||
/// <summary>Historique complet des appels (le plus récent en dernier).</summary>
|
||||
public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new();
|
||||
|
||||
private void Record(HttpMethod method, string path, object? body)
|
||||
{
|
||||
LastMethod = method;
|
||||
LastPath = path;
|
||||
LastBody = body;
|
||||
Calls.Add((method, path, body));
|
||||
}
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
Record(method, path, body);
|
||||
|
||||
if (Failure is not null)
|
||||
{
|
||||
throw Failure;
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(EstimateCreatedDto))
|
||||
{
|
||||
var payload = (EstimateDto)body!;
|
||||
var created = new EstimateCreatedDto { Id = 7, Bill = payload.Bill };
|
||||
return Task.FromResult((T)(object)created);
|
||||
}
|
||||
|
||||
return Task.FromResult(default(T)!);
|
||||
}
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
Record(method, path, body);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync(method, path, (object?)null, ct);
|
||||
|
||||
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
|
||||
{
|
||||
Record(method, path, null);
|
||||
return Task.FromResult(Array.Empty<byte>());
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
138
src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs
Normal file
138
src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
using System.Net.Http;
|
||||
using PostIt.ViewModels;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class EstimateListPageViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Client_perspective_calls_asclient_endpoint()
|
||||
{
|
||||
var api = new StubEstimateListApi();
|
||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front);
|
||||
|
||||
await vm.InitializeAsync();
|
||||
|
||||
Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths);
|
||||
Assert.Equal("Devis en attente de signature client", vm.Title);
|
||||
Assert.Equal(2, vm.Estimates.Count);
|
||||
// Most recent first
|
||||
Assert.Equal(2, vm.Estimates[0].Id);
|
||||
Assert.Equal(1, vm.Estimates[1].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_perspective_calls_asprovider_endpoint()
|
||||
{
|
||||
var api = new StubEstimateListApi();
|
||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front);
|
||||
|
||||
await vm.InitializeAsync();
|
||||
|
||||
Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths);
|
||||
Assert.Equal("Devis en attente de signature fournisseur", vm.Title);
|
||||
Assert.Equal(2, vm.Estimates.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilterText_filters_by_title_and_counterpart()
|
||||
{
|
||||
var api = new StubEstimateListApi();
|
||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front);
|
||||
|
||||
await vm.InitializeAsync();
|
||||
|
||||
vm.FilterText = "plomberie";
|
||||
Assert.Single(vm.Estimates);
|
||||
Assert.Equal(1, vm.Estimates[0].Id);
|
||||
|
||||
vm.FilterText = "BOB";
|
||||
Assert.Single(vm.Estimates);
|
||||
Assert.Equal("bob", vm.Estimates[0].ClientId);
|
||||
|
||||
vm.FilterText = string.Empty;
|
||||
Assert.Equal(2, vm.Estimates.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_result_sets_an_info_status()
|
||||
{
|
||||
var api = new StubEstimateListApi { Empty = true };
|
||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front);
|
||||
|
||||
await vm.InitializeAsync();
|
||||
|
||||
Assert.Empty(vm.Estimates);
|
||||
Assert.Contains("Aucun devis", vm.StatusMessage);
|
||||
}
|
||||
|
||||
private sealed class StubEstimateListApi : IYavscApiClient
|
||||
{
|
||||
public HttpClient Http { get; } = new();
|
||||
public List<string> Paths { get; } = new();
|
||||
public bool Empty { get; init; }
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
Paths.Add(path);
|
||||
|
||||
if (typeof(T) == typeof(List<EstimateDto>))
|
||||
{
|
||||
var items = Empty
|
||||
? new List<EstimateDto>()
|
||||
: new List<EstimateDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Devis plomberie",
|
||||
Description = "Réparation fuite",
|
||||
ClientId = "bob",
|
||||
OwnerId = "alice",
|
||||
CommandType = "Rdv",
|
||||
Bill = new List<EstimateLineDto>
|
||||
{
|
||||
new() { Name = "Main d'œuvre", Description = "Intervention", Count = 1, UnitaryCost = 80m },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2,
|
||||
Title = "Devis électricité",
|
||||
Description = "Mise aux normes",
|
||||
ClientId = "carol",
|
||||
OwnerId = "alice",
|
||||
CommandType = "Rdv",
|
||||
Bill = new List<EstimateLineDto>(),
|
||||
},
|
||||
};
|
||||
return Task.FromResult((T)(object)items);
|
||||
}
|
||||
|
||||
return Task.FromResult(default(T)!);
|
||||
}
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
Paths.Add(path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync(method, path, (object?)null, ct);
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
255
src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs
Normal file
255
src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using PostIt.Controls;
|
||||
using PostIt.ViewModels;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Models.Billing;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="EstimateValidationPageViewModel"/> wires the two
|
||||
/// signature pads (provider + client) into the right
|
||||
/// <see cref="FrontOfficeApiClient"/> call. The view (XAML, rendering)
|
||||
/// is not tested here; the VM is driven through the same
|
||||
/// <see cref="SignaturePadControl"/> test hooks the signature tests use,
|
||||
/// and the API client through a recording fake
|
||||
/// <see cref="IYavscApiClient"/>. The test host's
|
||||
/// <c>TestApp</c> makes <c>GoBackAsync</c> a no-op, so the success paths
|
||||
/// run to completion without navigation side effects.
|
||||
/// </summary>
|
||||
public class EstimateValidationPageViewModelTests
|
||||
{
|
||||
private static EstimateDto SampleEstimate(long? commandId = 42) => new()
|
||||
{
|
||||
Id = 7,
|
||||
CommandId = commandId,
|
||||
CommandType = "Rdv",
|
||||
Title = "Devis plomberie",
|
||||
Description = "Réparation",
|
||||
ClientId = "bob",
|
||||
OwnerId = "alice",
|
||||
};
|
||||
|
||||
private static void DrawOneStroke(SignaturePadControl pad)
|
||||
{
|
||||
pad.AppendPointForTest(100, 200);
|
||||
pad.AppendPointForTest(300, 400);
|
||||
pad.SealStrokeForTest();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_calls_accept_with_strokes_and_command_id()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
||||
|
||||
var proPad = new SignaturePadControl();
|
||||
var clientPad = new SignaturePadControl();
|
||||
vm.Attach(proPad, clientPad);
|
||||
DrawOneStroke(proPad); // provider signs on their writable pad
|
||||
|
||||
await vm.ValidateAsync();
|
||||
|
||||
Assert.Equal(HttpMethod.Post, api.LastMethod);
|
||||
Assert.Contains("front/query/accept", api.LastPath);
|
||||
Assert.Contains("billingCode=Rdv", api.LastPath);
|
||||
Assert.Contains("queryId=42", api.LastPath);
|
||||
var body = Assert.IsType<QueryAcceptanceRequestDto>(api.LastBody);
|
||||
Assert.NotNull(body.Strokes);
|
||||
Assert.NotEmpty(body.Strokes);
|
||||
Assert.Equal(10_000, body.CoordinateMax);
|
||||
// The provider perspective declares the Pro side, so the
|
||||
// server stores the signature under the provider's author
|
||||
// regardless of role inference.
|
||||
Assert.Equal(SignatureType.Pro, body.SignatureType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_from_client_perspective_declares_client_side()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Client, front);
|
||||
|
||||
var cliPad = new SignaturePadControl();
|
||||
vm.Attach(new SignaturePadControl(), cliPad);
|
||||
DrawOneStroke(cliPad); // client signs on their writable pad
|
||||
|
||||
await vm.ValidateAsync();
|
||||
|
||||
var body = Assert.IsType<QueryAcceptanceRequestDto>(api.LastBody);
|
||||
Assert.Equal(SignatureType.Client, body.SignatureType); // Client side
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reject_calls_reject_endpoint_with_no_body()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Client, front);
|
||||
|
||||
vm.Attach(new SignaturePadControl(), new SignaturePadControl());
|
||||
|
||||
await vm.RejectAsync();
|
||||
|
||||
Assert.Equal(HttpMethod.Post, api.LastMethod);
|
||||
Assert.Contains("front/query/reject", api.LastPath);
|
||||
Assert.Contains("queryId=42", api.LastPath);
|
||||
Assert.Null(api.LastBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_without_signature_warns_and_does_not_call()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
||||
|
||||
vm.Attach(new SignaturePadControl(), new SignaturePadControl()); // empty pads
|
||||
|
||||
await vm.ValidateAsync(); // no throw: returns before GoBack
|
||||
|
||||
Assert.Null(api.LastMethod);
|
||||
Assert.Contains("signer", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_command_gates_on_signature_and_command()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(commandId: null), EstimateListPerspective.Provider, front);
|
||||
|
||||
var proPad = new SignaturePadControl();
|
||||
vm.Attach(proPad, new SignaturePadControl());
|
||||
|
||||
// No CommandId ⇒ cannot validate even after signing.
|
||||
DrawOneStroke(proPad);
|
||||
Assert.False(vm.ValidateCommand.CanExecute(null));
|
||||
|
||||
var vm2 = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
||||
var proPad2 = new SignaturePadControl();
|
||||
vm2.Attach(proPad2, new SignaturePadControl());
|
||||
Assert.False(vm2.ValidateCommand.CanExecute(null)); // no signature yet
|
||||
DrawOneStroke(proPad2);
|
||||
Assert.True(vm2.ValidateCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoleLabel_reflects_perspective()
|
||||
{
|
||||
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
|
||||
var pro = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
||||
var cli = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Client, front);
|
||||
|
||||
Assert.Contains("prestataire", pro.RoleLabel, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("client", cli.RoleLabel, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_the_author_pad_is_writable()
|
||||
{
|
||||
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
|
||||
|
||||
var proVm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
||||
var proPad = new SignaturePadControl();
|
||||
var cliPad = new SignaturePadControl();
|
||||
proVm.Attach(proPad, cliPad);
|
||||
Assert.False(proPad.IsReadOnly); // provider writes the Pro pad
|
||||
Assert.True(cliPad.IsReadOnly);
|
||||
|
||||
var cliVm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Client, front);
|
||||
var proPad2 = new SignaturePadControl();
|
||||
var cliPad2 = new SignaturePadControl();
|
||||
cliVm.Attach(proPad2, cliPad2);
|
||||
Assert.True(proPad2.IsReadOnly);
|
||||
Assert.False(cliPad2.IsReadOnly); // client writes the Client pad
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_existing_signatures_renders_both_sides_in_their_pads()
|
||||
{
|
||||
var proStrokes = new[] { 1, 1000, 1000 };
|
||||
var cliStrokes = new[] { 1, 9000, 9000 };
|
||||
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
|
||||
var estimate = SampleEstimate();
|
||||
estimate.SignaturePro = new EstimateSignatureDto { Strokes = proStrokes };
|
||||
estimate.SignatureClient = new EstimateSignatureDto { Strokes = cliStrokes };
|
||||
|
||||
// Client perspective: both pads show the stored signatures, but
|
||||
// only the client pad is writable.
|
||||
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Client, front);
|
||||
var proPad = new SignaturePadControl();
|
||||
var cliPad = new SignaturePadControl();
|
||||
vm.Attach(proPad, cliPad);
|
||||
|
||||
Assert.Equal(proStrokes, proPad.Snapshot().Strokes);
|
||||
Assert.Equal(cliStrokes, cliPad.Snapshot().Strokes);
|
||||
Assert.True(vm.HasProSignature);
|
||||
Assert.True(vm.HasClientSignature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_existing_pro_signature_renders_in_pro_pad_only()
|
||||
{
|
||||
var strokes = new[] { 1, 5000, 5000 };
|
||||
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
|
||||
var estimate = SampleEstimate();
|
||||
estimate.SignaturePro = new EstimateSignatureDto { Strokes = strokes, CoordinateMax = 10_000 };
|
||||
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front);
|
||||
|
||||
var proPad = new SignaturePadControl();
|
||||
var cliPad = new SignaturePadControl();
|
||||
vm.Attach(proPad, cliPad);
|
||||
|
||||
Assert.Equal(strokes, proPad.Snapshot().Strokes);
|
||||
Assert.True(cliPad.Snapshot().IsEmpty);
|
||||
Assert.True(vm.HasProSignature);
|
||||
Assert.False(vm.HasClientSignature);
|
||||
}
|
||||
|
||||
private sealed class RecordingApi : IYavscApiClient
|
||||
{
|
||||
public HttpClient Http { get; } = new();
|
||||
public HttpMethod? LastMethod { get; private set; }
|
||||
public string? LastPath { get; private set; }
|
||||
public object? LastBody { get; private set; }
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
LastMethod = method;
|
||||
LastPath = path;
|
||||
LastBody = body;
|
||||
return Task.FromResult(default(T)!);
|
||||
}
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
LastMethod = method;
|
||||
LastPath = path;
|
||||
LastBody = body;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync(method, path, (object?)null, ct);
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
157
src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs
Normal file
157
src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
using System.Net.Http;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="FrontOfficeApiClient"/> issues the right
|
||||
/// <c>api/v1/front/query/accept</c> / <c>.../reject</c> requests with
|
||||
/// the PostIt signature payload, using a recording fake
|
||||
/// <see cref="IYavscApiClient"/> (no network).
|
||||
/// </summary>
|
||||
public class FrontOfficeApiClientTests
|
||||
{
|
||||
private static QueryAcceptanceRequestDto SampleBody() => new()
|
||||
{
|
||||
Strokes = new[] { 2, 100, 200, 300, 400 },
|
||||
CoordinateMax = 10_000,
|
||||
CapturedAtUtc = new DateTime(2026, 9, 21, 12, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Accept_posts_to_accept_endpoint_with_signature_body()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
|
||||
var body = SampleBody();
|
||||
await client.AcceptQueryAsync("Rdv", 42, body);
|
||||
|
||||
Assert.Equal(HttpMethod.Post, api.LastMethod);
|
||||
Assert.Equal(
|
||||
"https://business.example/api/v1/front/query/accept?billingCode=Rdv&queryId=42",
|
||||
api.LastPath);
|
||||
Assert.Same(body, api.LastBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reject_posts_to_reject_endpoint_with_no_body()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
|
||||
await client.RejectQueryAsync("Rdv", 42);
|
||||
|
||||
Assert.Equal(HttpMethod.Post, api.LastMethod);
|
||||
Assert.Equal(
|
||||
"https://business.example/api/v1/front/query/reject?billingCode=Rdv&queryId=42",
|
||||
api.LastPath);
|
||||
Assert.Null(api.LastBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Accept_returns_deserialized_response()
|
||||
{
|
||||
var api = new RecordingApi
|
||||
{
|
||||
Response = new QueryAcceptanceResponseDto
|
||||
{
|
||||
QueryId = 42,
|
||||
Status = "Accepted",
|
||||
Signature = new QuerySignatureRefDto { Id = 9, EstimateId = 5, Type = "Pro" },
|
||||
},
|
||||
};
|
||||
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
|
||||
var response = await client.AcceptQueryAsync("Rdv", 42, SampleBody());
|
||||
|
||||
Assert.Equal(42, response.QueryId);
|
||||
Assert.Equal("Accepted", response.Status);
|
||||
Assert.NotNull(response.Signature);
|
||||
Assert.Equal("Pro", response.Signature!.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Accept_rejects_invalid_arguments()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => client.AcceptQueryAsync("", 42, SampleBody()));
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.AcceptQueryAsync("Rdv", 0, SampleBody()));
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => client.AcceptQueryAsync("Rdv", 42, null!));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => client.RejectQueryAsync("", 42));
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.RejectQueryAsync("Rdv", 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEstimatePdf_gets_estimate_pdf_endpoint()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
|
||||
await client.GetEstimatePdfAsync(42);
|
||||
|
||||
Assert.Equal(HttpMethod.Get, api.LastMethod);
|
||||
Assert.Equal(
|
||||
"https://business.example/api/v1/front/query/42/estimate.pdf",
|
||||
api.LastPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEstimateTex_gets_estimate_tex_endpoint()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
|
||||
await client.GetEstimateTexAsync(42);
|
||||
|
||||
Assert.Equal(HttpMethod.Get, api.LastMethod);
|
||||
Assert.Equal(
|
||||
"https://business.example/api/v1/front/query/42/estimate.tex",
|
||||
api.LastPath);
|
||||
}
|
||||
|
||||
private sealed class RecordingApi : IYavscApiClient
|
||||
{
|
||||
public HttpClient Http { get; } = new();
|
||||
public HttpMethod? LastMethod { get; private set; }
|
||||
public string? LastPath { get; private set; }
|
||||
public object? LastBody { get; private set; }
|
||||
public QueryAcceptanceResponseDto? Response { get; set; }
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
LastMethod = method;
|
||||
LastPath = path;
|
||||
LastBody = body;
|
||||
|
||||
var response = Response ?? new QueryAcceptanceResponseDto();
|
||||
return Task.FromResult((T)(object)response);
|
||||
}
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
LastMethod = method;
|
||||
LastPath = path;
|
||||
LastBody = body;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync(method, path, (object?)null, ct);
|
||||
|
||||
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
|
||||
{
|
||||
LastMethod = method;
|
||||
LastPath = path;
|
||||
LastBody = null;
|
||||
return Task.FromResult(Array.Empty<byte>());
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
182
src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs
Normal file
182
src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
using Yavsc.Abstract.Identity;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class HomePageProviderFlowTests
|
||||
{
|
||||
[Fact]
|
||||
public void HomePage_exposes_provider_requests_command()
|
||||
{
|
||||
var vm = new HomePageViewModel();
|
||||
|
||||
Assert.NotNull(vm.OpenProviderRequests);
|
||||
Assert.True(vm.OpenProviderRequests.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HomePage_exposes_profile_command()
|
||||
{
|
||||
var vm = new HomePageViewModel();
|
||||
|
||||
Assert.NotNull(vm.OpenProfile);
|
||||
Assert.True(vm.OpenProfile.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HomePage_exposes_performer_configuration_command()
|
||||
{
|
||||
var vm = new HomePageViewModel();
|
||||
|
||||
Assert.NotNull(vm.OpenPerformerConfiguration);
|
||||
Assert.True(vm.OpenPerformerConfiguration.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HomePage_exposes_administration_command()
|
||||
{
|
||||
var vm = new HomePageViewModel();
|
||||
|
||||
Assert.NotNull(vm.OpenAdministration);
|
||||
Assert.True(vm.OpenAdministration.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Administration_page_filters_users_by_search_text()
|
||||
{
|
||||
var vm = new AdministrationPageViewModel();
|
||||
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example" });
|
||||
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "2", UserName = "bob", FullName = "Bob Jones" });
|
||||
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "3", UserName = "carol", FullName = "Carol Smith" });
|
||||
|
||||
vm.SearchText = "ali";
|
||||
|
||||
Assert.Single(vm.FilteredUsers);
|
||||
Assert.Equal("alice", vm.FilteredUsers[0].UserName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Administration_page_filters_users_by_full_name()
|
||||
{
|
||||
var vm = new AdministrationPageViewModel();
|
||||
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example" });
|
||||
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "2", UserName = "bob", FullName = "Bob Jones" });
|
||||
|
||||
vm.SearchText = "example";
|
||||
|
||||
Assert.Single(vm.FilteredUsers);
|
||||
Assert.Equal("alice", vm.FilteredUsers[0].UserName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Administration_page_tracks_selected_user()
|
||||
{
|
||||
var vm = new AdministrationPageViewModel();
|
||||
var user = new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example", Email = "alice@example.com", DedicatedGoogleCalendar = "calendar-123" };
|
||||
|
||||
vm.Users.Add(user);
|
||||
vm.SelectedUser = user;
|
||||
|
||||
Assert.Same(user, vm.SelectedUser);
|
||||
Assert.Equal("alice", vm.SelectedUser?.UserName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserProfilePage_exposes_full_name_and_address_fields()
|
||||
{
|
||||
var vm = new UserProfilePageViewModel();
|
||||
|
||||
Assert.Equal("—", vm.FullName);
|
||||
Assert.Equal("—", vm.Address);
|
||||
Assert.Equal("Non configuré", vm.DedicatedGoogleCalendar);
|
||||
Assert.Equal("Aucune information bancaire", vm.BankInfoSummary);
|
||||
Assert.NotNull(vm.RefreshCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProfileUpdateRequest_allows_username_updates()
|
||||
{
|
||||
var request = new ProfileUpdateRequest
|
||||
{
|
||||
UserName = "alice.new"
|
||||
};
|
||||
|
||||
Assert.Equal("alice.new", request.UserName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserProfilePage_can_save_profile_values()
|
||||
{
|
||||
var vm = new UserProfilePageViewModel
|
||||
{
|
||||
UserName = "alice.new",
|
||||
FullName = "Alice Example",
|
||||
Address = "14 rue de l’Érable",
|
||||
DedicatedGoogleCalendar = "calendar-123",
|
||||
BankInfoSummary = "FR14 2004 1010 0505 0001 3M02 606"
|
||||
};
|
||||
|
||||
Assert.NotNull(vm.SaveProfileCommand);
|
||||
Assert.False(vm.SaveProfileCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserProfilePage_builds_profile_payload_for_calendar_and_bank_values()
|
||||
{
|
||||
var vm = new UserProfilePageViewModel
|
||||
{
|
||||
UserName = "alice.new",
|
||||
FullName = "Alice Example",
|
||||
Address = "14 rue de l’Érable",
|
||||
DedicatedGoogleCalendar = "calendar-123",
|
||||
BankInfoSummary = "FR14 2004 1010 0505 0001 3M02 606"
|
||||
};
|
||||
|
||||
var payload = vm.BuildProfileUpdateRequest();
|
||||
|
||||
Assert.Equal("alice.new", payload.UserName);
|
||||
Assert.Equal("calendar-123", payload.GoogleCalendarId);
|
||||
Assert.Null(payload.BankInfoSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserProfilePage_turns_placeholder_values_into_empty_payload_fields()
|
||||
{
|
||||
var vm = new UserProfilePageViewModel
|
||||
{
|
||||
UserName = "alice.new",
|
||||
FullName = "Alice Example",
|
||||
Address = "14 rue de l’Érable",
|
||||
DedicatedGoogleCalendar = "Non configuré",
|
||||
BankInfoSummary = "Aucune information bancaire"
|
||||
};
|
||||
|
||||
var payload = vm.BuildProfileUpdateRequest();
|
||||
|
||||
Assert.Equal(string.Empty, payload.GoogleCalendarId);
|
||||
Assert.Null(payload.BankInfoSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerformerConfigurationPage_preserves_current_user_id_in_save_payload()
|
||||
{
|
||||
var vm = new PerformerConfigurationPageViewModel
|
||||
{
|
||||
PerformerId = "user-42",
|
||||
UserName = "alice",
|
||||
ExerciseCountryCode = "fr",
|
||||
SIREN = "123456789",
|
||||
Website = "https://example.com",
|
||||
Active = true,
|
||||
AcceptNotifications = true,
|
||||
AcceptPublicContact = true,
|
||||
UseGeoLocalizationToReduceDistanceWithClients = true
|
||||
};
|
||||
|
||||
var payload = vm.BuildPerformerProfileSettings();
|
||||
|
||||
Assert.Equal("user-42", payload.PerformerId);
|
||||
Assert.Equal("alice", payload.UserName);
|
||||
Assert.Equal("fr", payload.ExerciseCountryCode);
|
||||
}
|
||||
}
|
||||
34
src/PostIt/PostIt.Tests/MD/le-chant-1.md
Normal file
34
src/PostIt/PostIt.Tests/MD/le-chant-1.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
|
||||
Loin vers l'infini s'étendent
|
||||
Les grands prés marécageux .
|
||||
Pas un seul oiseau ne chante
|
||||
Sur les arbres secs et creux !
|
||||
|
||||
*(Refrain)*
|
||||
|
||||
Ô terre de détresse,
|
||||
Où nous devons sans cesse,
|
||||
Piocher, piocher .
|
||||
|
||||
Dans ce camp morne et sauvage
|
||||
Entouré de murs de fer,
|
||||
Il nous semble vivre en cage
|
||||
Au milieu d'un grand désert .
|
||||
|
||||
Bruit des pas et bruit des armes,
|
||||
Sentinelles jours et nuits,
|
||||
Et du sang, et des cris, des larmes,
|
||||
La mort pour celui qui fuit .
|
||||
|
||||
Mais un jour dans notre vie,
|
||||
Le printemps refleurira .
|
||||
Liberté, liberté chérie
|
||||
Je dirai : « Tu es à moi ! »
|
||||
|
||||
*(Dernier refrain)*
|
||||
|
||||
Ô terre d'allégresse,
|
||||
Où nous pourrons sans cesse,
|
||||
Aimer, aimer !
|
||||
|
||||
[audio : Mario Hacquard - Youtube](https://www.youtube.com/watch?v=ogw_mgk1pFs)
|
||||
|
|
@ -8,6 +8,8 @@ using Yavsc.Blogspot;
|
|||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using PostIt.Views.Blogs;
|
||||
using PostIt.Helpers;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
|
|
@ -72,13 +74,13 @@ public class MainPageButtonsTests
|
|||
{ }
|
||||
}
|
||||
|
||||
private static MainViewModel MakeViewModel(BlogPostDto? selectedPost = null)
|
||||
private static BlogsViewModel MakeViewModel(BlogPostDto? selectedPost = null)
|
||||
{
|
||||
var api = new ThrowingApi();
|
||||
var blog = new BlogApiClient(api, "http://localhost/");
|
||||
var circle = new CircleApiClient(api, "http://localhost/");
|
||||
var acl = new BlogAclApiClient(api, "http://localhost/");
|
||||
// Minimal DI graph: only what MainPageViewModel resolves
|
||||
// Minimal DI graph: only what BlogsViewModel resolves
|
||||
// when the user clicks a navigation button. Today that's
|
||||
// SignaturePageViewModel / CirclesPageViewModel / ACL
|
||||
// dependencies. The graph intentionally stays local to this
|
||||
|
|
@ -93,7 +95,7 @@ public class MainPageButtonsTests
|
|||
services.AddTransient<SignaturePage>();
|
||||
services.AddTransient<CirclesPage>();
|
||||
services.AddTransient<PostAclDialog>();
|
||||
var vm = new MainViewModel(blog, services: services.BuildServiceProvider());
|
||||
var vm = new BlogsViewModel(blog, services: services.BuildServiceProvider());
|
||||
if (selectedPost is not null) vm.SelectedPost = selectedPost;
|
||||
return vm;
|
||||
}
|
||||
|
|
@ -101,7 +103,7 @@ public class MainPageButtonsTests
|
|||
/// <summary>
|
||||
/// Mount a real <see cref="MainView"/> (as
|
||||
/// <c>SessionStatusBannerTests</c> does), push a
|
||||
/// <see cref="MainPage"/> with the given VM onto
|
||||
/// <see cref="BlogsPage"/> with the given VM onto
|
||||
/// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via
|
||||
/// <c>GetAwaiter().GetResult()</c>) so the page is on the
|
||||
/// nav stack before the test tries to interact with its
|
||||
|
|
@ -109,13 +111,17 @@ public class MainPageButtonsTests
|
|||
/// realised and <c>KeyPressQwerty</c> has a real
|
||||
/// <see cref="TopLevel"/> to dispatch against.
|
||||
/// </summary>
|
||||
private static (MainView window, MainPage page) MountMainPage(MainViewModel vm)
|
||||
private static (MainView window, BlogsPage page) MountMainPage(BlogsViewModel vm)
|
||||
{
|
||||
var window = new MainView();
|
||||
var page = new MainPage { DataContext = vm };
|
||||
var page = new BlogsPage { DataContext = vm };
|
||||
var app = (PostIt.App)Application.Current!;
|
||||
|
||||
app.AttachMainWindow(window);
|
||||
|
||||
window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
|
||||
var mainWindow = new Window { Content = window };
|
||||
mainWindow.Show();
|
||||
return (window, page);
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +151,7 @@ public class MainPageButtonsTests
|
|||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Acl_button_click_pushes_a_page_onto_nav_stack()
|
||||
public async Task Acl_button_click_pushes_a_page_onto_nav_stack()
|
||||
{
|
||||
// Arrange: a VM whose SelectedPost is non-null so
|
||||
// CanManageAcl evaluates to true and the button is
|
||||
|
|
@ -178,7 +184,7 @@ public class MainPageButtonsTests
|
|||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Circles_button_click_pushes_a_page_onto_nav_stack()
|
||||
public async Task Circles_button_click_pushes_a_page_onto_nav_stack()
|
||||
{
|
||||
// Arrange: OpenCircles has no CanExecute guard today —
|
||||
// any click should fire it and push the page.
|
||||
|
|
@ -203,7 +209,7 @@ public class MainPageButtonsTests
|
|||
public void Signature_dev_button_click_pushes_a_page_onto_nav_stack()
|
||||
{
|
||||
// Arrange: the "[DEV] Signature" button is bound to the
|
||||
// MainPageViewModel.OpenSignatureDevCommand [RelayCommand].
|
||||
// BlogsViewModel.OpenSignatureDevCommand [RelayCommand].
|
||||
// The click must push SignaturePage on top of NavRoot.
|
||||
// The ServiceCollection registered in MakeViewModel provides
|
||||
// SignaturePageViewModel so the command can resolve it via
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ using Yavsc.Blogspot;
|
|||
using Yavsc.Api.Client;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using PostIt.Views.Blogs;
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Headless UI tests for the "Save" flow in <see cref="MainPage"/>.
|
||||
/// Headless UI tests for the "Save" flow in <see cref="BlogsPage"/>.
|
||||
/// The pattern is the one <c>SessionStatusBannerTests</c>
|
||||
/// established: <c>[AvaloniaFact]</c>, a <see cref="Window"/>
|
||||
/// hosting the page (via a <see cref="Frame"/> because
|
||||
|
|
@ -40,9 +41,9 @@ public class MainPageSaveTests
|
|||
var recorder = new CallRecorder();
|
||||
var api = new RecordingYavscApiClient(recorder);
|
||||
var blog = new BlogApiClient(api, "http://localhost/");
|
||||
var viewModel = new MainViewModel(blog);
|
||||
var viewModel = new BlogsViewModel(blog);
|
||||
|
||||
var page = new MainPage { DataContext = viewModel };
|
||||
var page = new BlogsPage { DataContext = viewModel };
|
||||
// MainPage is a ContentPage (a Page, not a Control), so it
|
||||
// must be hosted in a navigation surface. The production
|
||||
// MainWindow.axaml uses NavigationPage, and the API is the
|
||||
|
|
@ -63,9 +64,7 @@ public class MainPageSaveTests
|
|||
const string typed = "Mon premier billet";
|
||||
titleBox.Text = typed;
|
||||
|
||||
var saveButton = window.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.Single(b => b.Content as string == "Save");
|
||||
var saveButton = page.SaveButton;
|
||||
saveButton.Command!.Execute(null);
|
||||
|
||||
// The Save command is async (RelayCommand over Task) but
|
||||
|
|
|
|||
143
src/PostIt/PostIt.Tests/MarkdownEditorScrollTests.cs
Normal file
143
src/PostIt/PostIt.Tests/MarkdownEditorScrollTests.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.LogicalTree;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using AvaloniaEdit;
|
||||
using PostIt.Controls;
|
||||
using PostIt.Views.Blogs;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class MarkdownEditorScrollTests
|
||||
{
|
||||
private static readonly string LongArticle =
|
||||
string.Join("\n\n", Enumerable.Range(1, 100).Select(i => $"Paragraph {i}: article content."));
|
||||
|
||||
[AvaloniaTheory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Long_article_scrolls_to_the_end_and_adapts_to_resizing(bool preview)
|
||||
{
|
||||
var editor = new MarkdownEditorControl
|
||||
{
|
||||
MarkdownText = LongArticle,
|
||||
IsPreviewVisible = preview
|
||||
};
|
||||
var window = new Window { Width = 600, Height = 400, Content = editor };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
|
||||
var scroller = FindArticleScroller(editor, preview);
|
||||
AssertScrollable(scroller);
|
||||
var initialViewport = scroller.Viewport.Height;
|
||||
|
||||
window.Height = 300;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
|
||||
Assert.True(scroller.Viewport.Height < initialViewport);
|
||||
AssertScrollable(scroller);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseWindow(window, editor);
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaTheory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Blog_page_bounds_the_article_viewport(bool preview)
|
||||
{
|
||||
var page = new BlogsPage();
|
||||
var editor = page.GetLogicalDescendants().OfType<MarkdownEditorControl>().Single();
|
||||
editor.MarkdownText = LongArticle;
|
||||
editor.IsPreviewVisible = preview;
|
||||
var posts = page.FindControl<ListBox>("PostsListBox")!;
|
||||
posts.ItemsSource = Enumerable.Range(1, 50)
|
||||
.Select(i => new Yavsc.Blogspot.BlogPostDto { Title = $"Post {i}" }).ToArray();
|
||||
var navigation = new NavigationPage();
|
||||
navigation.PushAsync(page).GetAwaiter().GetResult();
|
||||
var window = new Window { Width = 800, Height = 700, Content = navigation };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
|
||||
var scroller = FindArticleScroller(editor, preview);
|
||||
Assert.InRange(scroller.Viewport.Height, 1, window.ClientSize.Height);
|
||||
AssertScrollable(scroller);
|
||||
AssertScrollable(posts.GetVisualDescendants().OfType<ScrollViewer>().Single());
|
||||
var editorBottom = editor.TranslatePoint(new Point(0, editor.Bounds.Height), window);
|
||||
Assert.NotNull(editorBottom);
|
||||
Assert.InRange(editorBottom.Value.Y, 1, window.ClientSize.Height);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseWindow(window, editor);
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaTheory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Short_article_needs_no_vertical_scrollbar(bool preview)
|
||||
{
|
||||
var editor = new MarkdownEditorControl
|
||||
{
|
||||
MarkdownText = "Short article",
|
||||
IsPreviewVisible = preview
|
||||
};
|
||||
var window = new Window { Width = 600, Height = 400, Content = editor };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
|
||||
var scroller = FindArticleScroller(editor, preview);
|
||||
Assert.True(scroller.Extent.Height <= scroller.Viewport.Height);
|
||||
Assert.DoesNotContain(scroller.GetVisualDescendants().OfType<ScrollBar>(),
|
||||
bar => bar.Orientation == Avalonia.Layout.Orientation.Vertical && bar.IsVisible);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseWindow(window, editor);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CloseWindow(Window window, MarkdownEditorControl editor)
|
||||
{
|
||||
// The package's shared renderer caches the visual; detach it between headless applications.
|
||||
editor.FindControl<MarkdownViewer.Core.Controls.MarkdownViewer>("PreviewViewer")!.Content = null;
|
||||
window.UpdateLayout();
|
||||
window.Close();
|
||||
}
|
||||
|
||||
private static ScrollViewer FindArticleScroller(MarkdownEditorControl editor, bool preview)
|
||||
{
|
||||
if (!preview)
|
||||
return editor.GetVisualDescendants().OfType<TextEditor>().Single()
|
||||
.GetVisualDescendants().OfType<ScrollViewer>().Single();
|
||||
|
||||
return editor.FindControl<ScrollViewer>("PreviewScrollViewer")!;
|
||||
}
|
||||
|
||||
private static void AssertScrollable(ScrollViewer scroller)
|
||||
{
|
||||
Assert.True(scroller.Extent.Height > scroller.Viewport.Height,
|
||||
$"Expected overflow, but extent={scroller.Extent.Height}, viewport={scroller.Viewport.Height}.");
|
||||
Assert.Contains(scroller.GetVisualDescendants().OfType<ScrollBar>(),
|
||||
bar => bar.Orientation == Avalonia.Layout.Orientation.Vertical && bar.IsVisible);
|
||||
scroller.ScrollToEnd();
|
||||
Assert.True(scroller.Offset.Y > 0);
|
||||
Assert.Equal(scroller.Extent.Height - scroller.Viewport.Height, scroller.Offset.Y, 1);
|
||||
}
|
||||
}
|
||||
208
src/PostIt/PostIt.Tests/MyFilesPageHeadlessTests.cs
Normal file
208
src/PostIt/PostIt.Tests/MyFilesPageHeadlessTests.cs
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
using System.Net.Http;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.VisualTree;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Headless UI coverage for the "Mes fichiers" page
|
||||
/// (<see cref="MyFilesPage"/>). The user reported that once a file
|
||||
/// is listed, "l'item est inerte au click, et ses boutons restent
|
||||
/// grisés, désactivés" — neither the row nor its action buttons
|
||||
/// (Télécharger / Supprimer) respond to a click.
|
||||
///
|
||||
/// <para>These tests mount a real <see cref="MyFilesPage"/> under
|
||||
/// the Avalonia headless platform with a stub
|
||||
/// <see cref="IYavscApiClient"/> that lists one file, then walk the
|
||||
/// realised visual tree to find the row's action buttons and
|
||||
/// assert the two things that would silently break and reproduce
|
||||
/// the "grayed / inert" symptom:</para>
|
||||
/// <list type="number">
|
||||
/// <item><description>The <c>Command</c> binding
|
||||
/// (<c>$parent[vm:MyFilesViewModel].DownloadFileCommand</c>)
|
||||
/// resolved to a non-null command — a null command leaves the
|
||||
/// button disabled (grayed) and click-silent.</description></item>
|
||||
/// <item><description>The command is <b>armed</b>
|
||||
/// (<c>CanExecute</c> true) for the listed file — an
|
||||
/// <c>AsyncRelayCommand</c> with
|
||||
/// <c>AllowConcurrentExecutions=false</c> (the
|
||||
/// <see cref="CommunityToolkit.Mvvm.Input.RelayCommandAttribute"/>
|
||||
/// default) disables itself while running; a button stuck
|
||||
/// "running" is exactly the grayed-and-inert report.</description></item>
|
||||
/// <item><description>Executing the command actually invokes the
|
||||
/// file download / delete on the API client — the row
|
||||
/// "activates" on click instead of being inert.</description></item>
|
||||
/// </list>
|
||||
/// <para>The OS save picker is absent under headless, so
|
||||
/// <c>FileSaveHelpers.SaveAsync</c> throws or cancels and the
|
||||
/// download command's <c>catch</c> sets an error status — but the
|
||||
/// API download is awaited <em>before</em> the picker, so the call
|
||||
/// is recorded regardless. We do not <c>await</c> the command's
|
||||
/// <c>ExecutionTask</c> to completion, to avoid blocking on the
|
||||
/// (platform-dependent) picker.</para>
|
||||
/// </summary>
|
||||
public class MyFilesPageHeadlessTests
|
||||
{
|
||||
private static (MyFilesPage page, MyFilesViewModel vm, StubFsApi api) CreatePage()
|
||||
{
|
||||
var api = new StubFsApi();
|
||||
var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/");
|
||||
var vm = new MyFilesViewModel(fsClient);
|
||||
var page = new MyFilesPage { DataContext = vm };
|
||||
return (page, vm, api);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mount the page the way the app does — pushed onto a
|
||||
/// <see cref="NavigationPage"/> shown in a <see cref="Window"/>
|
||||
/// (a <see cref="ContentPage"/> realizes its content template via
|
||||
/// the navigation host, cf. <c>RdvPageHeadlessTests</c>) — after
|
||||
/// listing a single file by awaiting
|
||||
/// <see cref="MyFilesViewModel.InitializeAsync"/>. Populating
|
||||
/// <see cref="MyFilesViewModel.Files"/> before the first layout
|
||||
/// pass lets the <c>ItemsControl</c> realise its item templates
|
||||
/// under headless.
|
||||
/// </summary>
|
||||
private static async Task<(MyFilesPage page, MyFilesViewModel vm, StubFsApi api)> MountAsync()
|
||||
{
|
||||
var (page, vm, api) = CreatePage();
|
||||
await vm.InitializeAsync();
|
||||
var nav = new NavigationPage();
|
||||
nav.PushAsync(page).GetAwaiter().GetResult();
|
||||
var window = new Window { Content = nav };
|
||||
window.Show();
|
||||
// Let bindings / the ItemsControl item templates apply.
|
||||
await Task.Delay(50);
|
||||
return (page, vm, api);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find the first action button in a file row whose content
|
||||
/// matches <paramref name="content"/>. The file rows live in an
|
||||
/// <c>ItemsControl</c> DataTemplate (no <c>x:Name</c>), so we
|
||||
/// walk the visual tree.
|
||||
/// </summary>
|
||||
private static Button? FindRowButton(MyFilesPage page, string content)
|
||||
=> page.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.FirstOrDefault(b => b.Content is string s && s == content);
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Download_button_on_a_file_row_is_bound_armed_and_fires_on_click()
|
||||
{
|
||||
var (page, vm, api) = await MountAsync();
|
||||
|
||||
// Sanity: the listing populated a file row.
|
||||
Assert.NotEmpty(vm.Files);
|
||||
Assert.NotNull(vm.Files[0].Id);
|
||||
|
||||
var downloadBtn = FindRowButton(page, "Télécharger");
|
||||
Assert.NotNull(downloadBtn);
|
||||
Assert.NotNull(downloadBtn!.Command); // binding resolved (not grayed-via-null)
|
||||
var param = downloadBtn.CommandParameter;
|
||||
Assert.True(downloadBtn.Command!.CanExecute(param), // armed (not stuck running)
|
||||
"Télécharger must be armed (CanExecute true) for a listed file.");
|
||||
|
||||
// Act: click → fire the command.
|
||||
downloadBtn.Command.Execute(param);
|
||||
|
||||
// Assert: the row activated — the API download was invoked
|
||||
// (it is awaited before the headless-absent save picker).
|
||||
var recorded = false;
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
if (api.Calls.Any(c => c.Method == HttpMethod.Get && c.Path == "fs/file/42"))
|
||||
{
|
||||
recorded = true;
|
||||
break;
|
||||
}
|
||||
await Task.Delay(25);
|
||||
}
|
||||
Assert.True(recorded,
|
||||
"Clicking Télécharger must invoke GET fs/file/{id} on the API client.");
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Delete_button_on_a_file_row_is_bound_armed_and_fires_on_click()
|
||||
{
|
||||
var (page, vm, api) = await MountAsync();
|
||||
|
||||
Assert.NotEmpty(vm.Files);
|
||||
|
||||
var deleteBtn = FindRowButton(page, "Supprimer");
|
||||
Assert.NotNull(deleteBtn);
|
||||
Assert.NotNull(deleteBtn!.Command);
|
||||
var param = deleteBtn.CommandParameter;
|
||||
Assert.True(deleteBtn.Command!.CanExecute(param),
|
||||
"Supprimer must be armed (CanExecute true) for a listed file.");
|
||||
|
||||
deleteBtn.Command.Execute(param);
|
||||
|
||||
var recorded = false;
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
if (api.Calls.Any(c => c.Method == HttpMethod.Delete))
|
||||
{
|
||||
recorded = true;
|
||||
break;
|
||||
}
|
||||
await Task.Delay(25);
|
||||
}
|
||||
Assert.True(recorded,
|
||||
"Clicking Supprimer must invoke DELETE fs/{path} on the API client.");
|
||||
}
|
||||
|
||||
private sealed class StubFsApi : IYavscApiClient
|
||||
{
|
||||
public HttpClient Http { get; } = new();
|
||||
|
||||
public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new();
|
||||
|
||||
private void Record(HttpMethod method, string path, object? body)
|
||||
=> Calls.Add((method, path, body));
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
Record(method, path, body);
|
||||
if (typeof(T) == typeof(UserDirectoryDto))
|
||||
{
|
||||
var dir = new UserDirectoryDto
|
||||
{
|
||||
UserName = "alice",
|
||||
Files = new[]
|
||||
{
|
||||
new FileEntryDto { Id = 42, Name = "note.txt", Size = 4 }
|
||||
}
|
||||
};
|
||||
return Task.FromResult((T)(object)dir);
|
||||
}
|
||||
return Task.FromResult(default(T)!);
|
||||
}
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
Record(method, path, body);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync(method, path, (object?)null, ct);
|
||||
|
||||
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
|
||||
{
|
||||
Record(method, path, null);
|
||||
return Task.FromResult(new byte[] { 1, 2, 3, 4 });
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -21,9 +21,11 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
|
|||
private readonly RSA _rsa;
|
||||
private readonly string _kid;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly HashSet<string> _refreshTokens = new(StringComparer.Ordinal);
|
||||
|
||||
public string Issuer { get; }
|
||||
public string LoopbackRedirectUri { get; }
|
||||
public int RefreshRequestCount { get; private set; }
|
||||
|
||||
private OIDCStubAuthority(HttpListener listener, RSA rsa, string kid, string issuer, string loopback)
|
||||
{
|
||||
|
|
@ -100,7 +102,7 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
|
|||
["response_types_supported"] = new[] { "code" },
|
||||
["subject_types_supported"] = new[] { "public" },
|
||||
["id_token_signing_alg_values_supported"] = new[] { "RS256" },
|
||||
["grant_types_supported"] = new[] { "authorization_code" },
|
||||
["grant_types_supported"] = new[] { "authorization_code", "refresh_token" },
|
||||
["code_challenge_methods_supported"] = new[] { "S256" },
|
||||
};
|
||||
|
||||
|
|
@ -132,6 +134,16 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
|
|||
body = await reader.ReadToEndAsync();
|
||||
|
||||
var form = ParseForm(body);
|
||||
if (form.TryGetValue("grant_type", out var grantType) && grantType == "refresh_token")
|
||||
{
|
||||
RefreshRequestCount++;
|
||||
if (!form.TryGetValue("refresh_token", out var suppliedToken)
|
||||
|| !_refreshTokens.Remove(suppliedToken))
|
||||
{
|
||||
await WriteJsonAsync(ctx.Response, new { error = "invalid_grant" }, 400);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// We accept any code and don't validate PKCE on the stub side;
|
||||
// the OidcClient itself validates the redirect_uri match.
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
|
@ -142,11 +154,13 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
|
|||
["aud"] = form.TryGetValue("client_id", out var cid) ? cid : "postit-tests",
|
||||
["exp"] = now + 600,
|
||||
["iat"] = now,
|
||||
["jti"] = Guid.NewGuid().ToString("N"),
|
||||
};
|
||||
|
||||
var accessToken = SignJwt(claims);
|
||||
var refreshToken = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
|
||||
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
_refreshTokens.Add(refreshToken);
|
||||
var response = new
|
||||
{
|
||||
access_token = accessToken,
|
||||
|
|
@ -193,10 +207,10 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
|
|||
return dict;
|
||||
}
|
||||
|
||||
private static async Task WriteJsonAsync(HttpListenerResponse response, object payload)
|
||||
private static async Task WriteJsonAsync(HttpListenerResponse response, object payload, int statusCode = 200)
|
||||
{
|
||||
response.ContentType = "application/json";
|
||||
response.StatusCode = 200;
|
||||
response.StatusCode = statusCode;
|
||||
var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload));
|
||||
await response.OutputStream.WriteAsync(bytes);
|
||||
response.Close();
|
||||
|
|
|
|||
|
|
@ -270,6 +270,12 @@ public class PostAclDialogTests
|
|||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync(method, path, (object?)null, ct);
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,14 +8,13 @@
|
|||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Xamarin.UITest" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Avalonia.Headless" />
|
||||
<PackageReference Include="Avalonia.Headless.XUnit" />
|
||||
|
|
|
|||
|
|
@ -1,35 +1,153 @@
|
|||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using Yavsc.Blogspot;
|
||||
using Yavsc.Api.Client;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views.Blogs;
|
||||
|
||||
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()
|
||||
{
|
||||
var post = new BlogPostDto { Id = 42, Title = "Test post", IsPublished = true };
|
||||
var page = new BlogsPage();
|
||||
var row = page.PostsListBox.ItemTemplate!.Build(post)!;
|
||||
row.DataContext = post;
|
||||
var window = new Window { Content = row };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
var badge = Assert.Single(row.GetVisualDescendants().OfType<Border>(),
|
||||
border => border.Name == "PublishedBadge");
|
||||
|
||||
Assert.True(badge.IsVisible);
|
||||
|
||||
post.IsPublished = false;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.False(badge.IsVisible);
|
||||
|
||||
post.IsPublished = true;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.True(badge.IsVisible);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchCommand_filters_posts_by_title_article_or_author()
|
||||
{
|
||||
// MainPageViewModel no longer owns a BlogApiClient instance by
|
||||
// BlogsViewModel no longer owns a BlogApiClient instance by
|
||||
// default; tests construct one with a fake YavscApiClient that
|
||||
// throws on any call (we never call the API in this test).
|
||||
var fakeApi = new ThrowingYavscApiClient();
|
||||
var blog = new BlogApiClient(fakeApi, "http://localhost/");
|
||||
var viewModel = new MainViewModel(blog);
|
||||
var viewModel = new BlogsViewModel(blog);
|
||||
|
||||
viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" });
|
||||
viewModel.Posts.Add(new BlogPostDto { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" });
|
||||
viewModel.Posts.Add(new BlogPostDto { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" });
|
||||
|
||||
viewModel.SearchText = "search";
|
||||
viewModel.SearchCommand.Execute(null);
|
||||
|
||||
Assert.Single(viewModel.FilteredPosts);
|
||||
Assert.Equal(3, viewModel.FilteredPosts[0].Id);
|
||||
|
||||
viewModel.SearchText = "bob";
|
||||
viewModel.SearchCommand.Execute(null);
|
||||
|
||||
Assert.Single(viewModel.FilteredPosts);
|
||||
Assert.Equal(2, viewModel.FilteredPosts[0].Id);
|
||||
|
|
@ -60,14 +178,25 @@ public class PostItViewModelTests
|
|||
{
|
||||
var api = new RecordingPublishApi();
|
||||
var blog = new BlogApiClient(api, "http://localhost/");
|
||||
var viewModel = new MainViewModel(blog);
|
||||
var viewModel = new BlogsViewModel(blog);
|
||||
|
||||
viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false };
|
||||
var changes = new List<string?>();
|
||||
viewModel.SelectedPost.PropertyChanged += (_, args) => changes.Add(args.PropertyName);
|
||||
|
||||
await viewModel.SetPublishStateAsync(true);
|
||||
|
||||
Assert.True(api.LastPublishValue);
|
||||
Assert.True(viewModel.DraftIsPublished);
|
||||
Assert.True(viewModel.SelectedPost.IsPublished);
|
||||
Assert.Equal(new[] { nameof(BlogPostDto.IsPublished) }, changes);
|
||||
|
||||
await viewModel.SetPublishStateAsync(false);
|
||||
|
||||
Assert.False(api.LastPublishValue);
|
||||
Assert.False(viewModel.DraftIsPublished);
|
||||
Assert.False(viewModel.SelectedPost.IsPublished);
|
||||
Assert.Equal(new[] { nameof(BlogPostDto.IsPublished), nameof(BlogPostDto.IsPublished) }, changes);
|
||||
}
|
||||
|
||||
/// <summary>Test fake that always throws if the API is invoked.</summary>
|
||||
|
|
@ -146,6 +275,12 @@ public class PostItViewModelTests
|
|||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync(method, path, (object?)null, ct);
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,238 @@
|
|||
using System.Net.Http;
|
||||
using PostIt.ViewModels;
|
||||
using Yavsc;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class ProviderOngoingRequestsPageViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RefreshAsync_calls_provider_endpoint_and_filters_out_unknown_billing_codes()
|
||||
{
|
||||
var api = new StubProviderApi();
|
||||
var client = new BillingApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new ProviderOngoingRequestsPageViewModel(client);
|
||||
|
||||
await vm.InitializeAsync();
|
||||
|
||||
Assert.Contains("https://business.example/api/v1/bill/provider/ongoing", api.Paths);
|
||||
Assert.Equal(3, vm.Queries.Count);
|
||||
Assert.Equal(12, vm.Queries[0].Id);
|
||||
Assert.Equal(11, vm.Queries[1].Id);
|
||||
Assert.Equal(10, vm.Queries[2].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilterText_filters_by_activity_code_and_status()
|
||||
{
|
||||
var api = new StubProviderApi();
|
||||
var client = new BillingApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new ProviderOngoingRequestsPageViewModel(client);
|
||||
|
||||
await vm.InitializeAsync();
|
||||
|
||||
vm.FilterText = "mbrush";
|
||||
Assert.Single(vm.Queries);
|
||||
Assert.Equal("MBrush", vm.Queries[0].BillingCode);
|
||||
|
||||
vm.FilterText = "accepted";
|
||||
Assert.Single(vm.Queries);
|
||||
Assert.Equal(QueryStatus.Accepted, vm.Queries[0].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenSelectedEditorCommand_can_execute_only_when_selection_exists()
|
||||
{
|
||||
var api = new StubProviderApi();
|
||||
var client = new BillingApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new ProviderOngoingRequestsPageViewModel(client);
|
||||
|
||||
await vm.InitializeAsync();
|
||||
|
||||
Assert.False(vm.OpenSelectedEditorCommand.CanExecute(null));
|
||||
|
||||
vm.SelectedQuery = vm.Queries[0];
|
||||
|
||||
Assert.True(vm.OpenSelectedEditorCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectedSortOption_date_keeps_most_recent_first()
|
||||
{
|
||||
var api = new StubProviderApi();
|
||||
var client = new BillingApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new ProviderOngoingRequestsPageViewModel(client);
|
||||
|
||||
await vm.InitializeAsync();
|
||||
vm.SelectedSortOption = ProviderOngoingRequestsPageViewModel.SortByDate;
|
||||
|
||||
Assert.Equal(new long[] { 12, 11, 10 }, vm.Queries.Select(q => q.Id).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectedSortOption_date_ascending_keeps_oldest_first()
|
||||
{
|
||||
var api = new StubProviderApi();
|
||||
var client = new BillingApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new ProviderOngoingRequestsPageViewModel(client);
|
||||
|
||||
await vm.InitializeAsync();
|
||||
vm.SelectedSortOption = ProviderOngoingRequestsPageViewModel.SortByDateAsc;
|
||||
|
||||
Assert.Equal(new long[] { 10, 11, 12 }, vm.Queries.Select(q => q.Id).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectedSortOption_status_prioritizes_inprogress_then_accepted_then_inserted()
|
||||
{
|
||||
var api = new StubProviderApi();
|
||||
var client = new BillingApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new ProviderOngoingRequestsPageViewModel(client);
|
||||
|
||||
await vm.InitializeAsync();
|
||||
vm.SelectedSortOption = ProviderOngoingRequestsPageViewModel.SortByStatus;
|
||||
|
||||
Assert.Equal(new long[] { 11, 12, 10 }, vm.Queries.Select(q => q.Id).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_reads_saved_sort_option_from_settings()
|
||||
{
|
||||
var api = new StubProviderApi();
|
||||
var client = new BillingApiClient(api, "https://business.example/api/v1/");
|
||||
var settings = new Settings
|
||||
{
|
||||
ProviderOngoingRequestsSortOption = ProviderOngoingRequestsPageViewModel.SortByStatus,
|
||||
};
|
||||
|
||||
var vm = new ProviderOngoingRequestsPageViewModel(client, settings);
|
||||
|
||||
Assert.Equal(ProviderOngoingRequestsPageViewModel.SortByStatus, vm.SelectedSortOption);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_falls_back_to_default_when_saved_sort_is_invalid()
|
||||
{
|
||||
var api = new StubProviderApi();
|
||||
var client = new BillingApiClient(api, "https://business.example/api/v1/");
|
||||
var settings = new Settings
|
||||
{
|
||||
ProviderOngoingRequestsSortOption = "invalide",
|
||||
};
|
||||
|
||||
var vm = new ProviderOngoingRequestsPageViewModel(client, settings);
|
||||
|
||||
Assert.Equal(ProviderOngoingRequestsPageViewModel.SortByDate, vm.SelectedSortOption);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateEstimateForSelectedCommand_requires_a_selection()
|
||||
{
|
||||
var api = new StubProviderApi();
|
||||
var client = new BillingApiClient(api, "https://business.example/api/v1/");
|
||||
var estimateClient = new EstimateApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new ProviderOngoingRequestsPageViewModel(client, estimateClient: estimateClient);
|
||||
|
||||
await vm.InitializeAsync();
|
||||
|
||||
Assert.False(vm.CreateEstimateForSelectedCommand.CanExecute(null));
|
||||
|
||||
vm.SelectedQuery = vm.Queries[0];
|
||||
|
||||
Assert.True(vm.CreateEstimateForSelectedCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateEstimateForSelectedCommand_is_disabled_without_estimate_client()
|
||||
{
|
||||
var api = new StubProviderApi();
|
||||
var client = new BillingApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new ProviderOngoingRequestsPageViewModel(client);
|
||||
|
||||
await vm.InitializeAsync();
|
||||
vm.SelectedQuery = vm.Queries[0];
|
||||
|
||||
Assert.False(vm.CreateEstimateForSelectedCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
private sealed class StubProviderApi : IYavscApiClient
|
||||
{
|
||||
public HttpClient Http { get; } = new();
|
||||
public List<string> Paths { get; } = new();
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
Paths.Add(path);
|
||||
|
||||
if (typeof(T) == typeof(List<BillingQuerySummaryDto>))
|
||||
{
|
||||
var data = new List<BillingQuerySummaryDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 10,
|
||||
BillingCode = "Rdv",
|
||||
ActivityCode = "dev",
|
||||
PerformerId = "perf-1",
|
||||
ClientId = "cli-1",
|
||||
Status = QueryStatus.Inserted,
|
||||
Description = "Rendez-vous",
|
||||
EventDate = new DateTime(2026, 9, 10, 10, 0, 0, DateTimeKind.Utc),
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 11,
|
||||
BillingCode = "MBrush",
|
||||
ActivityCode = "hair",
|
||||
PerformerId = "perf-1",
|
||||
ClientId = "cli-2",
|
||||
Status = QueryStatus.InProgress,
|
||||
Description = "Coupe multiple",
|
||||
EventDate = new DateTime(2026, 9, 11, 10, 0, 0, DateTimeKind.Utc),
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 12,
|
||||
BillingCode = "Brush",
|
||||
ActivityCode = "hair",
|
||||
PerformerId = "perf-1",
|
||||
ClientId = "cli-3",
|
||||
Status = QueryStatus.Accepted,
|
||||
Description = "Coupe simple",
|
||||
EventDate = new DateTime(2026, 9, 12, 10, 0, 0, DateTimeKind.Utc),
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 13,
|
||||
BillingCode = "",
|
||||
ActivityCode = "unknown",
|
||||
PerformerId = "perf-1",
|
||||
ClientId = "cli-4",
|
||||
Status = QueryStatus.Accepted,
|
||||
Description = "Doit être filtrée",
|
||||
EventDate = new DateTime(2026, 9, 13, 10, 0, 0, DateTimeKind.Utc),
|
||||
}
|
||||
};
|
||||
|
||||
return Task.FromResult((T)(object)data);
|
||||
}
|
||||
|
||||
return Task.FromResult(default(T)!);
|
||||
}
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
Paths.Add(path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync(method, path, (object?)null, ct);
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -74,6 +74,12 @@ public class RdvPageHeadlessTests
|
|||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync(method, path, (object?)null, ct);
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
117
src/PostIt/PostIt.Tests/ResourceUsageApiClientTests.cs
Normal file
117
src/PostIt/PostIt.Tests/ResourceUsageApiClientTests.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using Yavsc.Abstract.Resources;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class ResourceUsageApiClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetCurrentAsync_calls_user_usage_endpoint()
|
||||
{
|
||||
var fake = new FakeYavscApiClient();
|
||||
var client = new ResourceUsageApiClient(fake, "https://example.invalid/api/v1/");
|
||||
|
||||
var usage = await client.GetCurrentAsync();
|
||||
|
||||
Assert.Equal("https://example.invalid/api/v1/resource-usage/me", fake.LastPath);
|
||||
Assert.Equal("GET", fake.LastMethod);
|
||||
Assert.Equal(12.5m, usage.ApiCalls);
|
||||
Assert.Equal(3.2m, usage.CpuSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAdminOverviewAsync_calls_admin_usage_endpoint()
|
||||
{
|
||||
var fake = new FakeYavscApiClient();
|
||||
var client = new ResourceUsageApiClient(fake, "https://example.invalid/api/v1/");
|
||||
|
||||
var usage = await client.GetAdminOverviewAsync();
|
||||
|
||||
Assert.Equal("https://example.invalid/api/v1/resource-usage/admin", fake.LastPath);
|
||||
Assert.Equal("GET", fake.LastMethod);
|
||||
Assert.Equal(2, usage.Count);
|
||||
Assert.Equal("administrator", usage[0].UserName);
|
||||
}
|
||||
|
||||
private sealed class FakeYavscApiClient : IYavscApiClient
|
||||
{
|
||||
public HttpClient Http { get; } = new();
|
||||
public string? LastPath { get; private set; }
|
||||
public string? LastMethod { get; private set; }
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
LastMethod = method.Method;
|
||||
LastPath = path;
|
||||
|
||||
if (typeof(T) == typeof(ResourceUsageSummary))
|
||||
{
|
||||
var summary = new ResourceUsageSummary
|
||||
{
|
||||
UserId = "user-42",
|
||||
UserName = "alice",
|
||||
ApiCalls = 12.5m,
|
||||
CpuSeconds = 3.2m,
|
||||
BandwidthMb = 18.4m,
|
||||
StorageMb = 51.2m,
|
||||
EstimatedCompensation = 4.9m,
|
||||
Currency = "EUR",
|
||||
RecordedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
return Task.FromResult((T)(object)summary);
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(List<ResourceUsageSummary>))
|
||||
{
|
||||
var list = new List<ResourceUsageSummary>
|
||||
{
|
||||
new()
|
||||
{
|
||||
UserId = "admin-user",
|
||||
UserName = "administrator",
|
||||
ApiCalls = 90m,
|
||||
CpuSeconds = 14m,
|
||||
BandwidthMb = 500m,
|
||||
StorageMb = 2048m,
|
||||
EstimatedCompensation = 32m,
|
||||
Currency = "EUR",
|
||||
RecordedAtUtc = DateTimeOffset.UtcNow
|
||||
},
|
||||
new()
|
||||
{
|
||||
UserId = "user-99",
|
||||
UserName = "bob",
|
||||
ApiCalls = 40m,
|
||||
CpuSeconds = 7m,
|
||||
BandwidthMb = 120m,
|
||||
StorageMb = 600m,
|
||||
EstimatedCompensation = 15m,
|
||||
Currency = "EUR",
|
||||
RecordedAtUtc = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
return Task.FromResult((T)(object)list);
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"Unsupported type: {typeof(T).FullName}");
|
||||
}
|
||||
|
||||
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)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
|
||||
=> Task.FromResult(Array.Empty<byte>());
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.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");
|
||||
|
||||
|
|
@ -71,10 +65,7 @@ public class SessionStatusBannerTests
|
|||
{
|
||||
SessionStatusBanner banner = CreateBanner();
|
||||
|
||||
Assert.False((banner.DataContext as SessionStatusViewModel)!
|
||||
.IsLoggedIn); // VM default
|
||||
|
||||
|
||||
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 = window.MainView.SessionBanner;
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
38
src/PostIt/PostIt.Tests/StatusBarTests.cs
Normal file
38
src/PostIt/PostIt.Tests/StatusBarTests.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.VisualTree;
|
||||
using PostIt.Controls;
|
||||
using PostIt.Models;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="StatusBar"/> surfaces the page's action status
|
||||
/// (success / warning / error message). The user reported being
|
||||
/// unable to copy these messages — the status text was a
|
||||
/// <c>TextBlock</c>, which Avalonia does not make selectable. It is
|
||||
/// now a <see cref="SelectableTextBlock"/> so the message can be
|
||||
/// selected and copied (Ctrl+C / context menu). This test pins that:
|
||||
/// if the message control regresses to a plain <c>TextBlock</c>,
|
||||
/// selectability is lost and the test fails.
|
||||
/// </summary>
|
||||
public class StatusBarTests
|
||||
{
|
||||
[AvaloniaFact]
|
||||
public void Status_message_is_rendered_by_a_SelectableTextBlock()
|
||||
{
|
||||
var bar = new StatusBar {
|
||||
DataContext =
|
||||
StatusNotice.Error("Échec de la suppression : boom") };
|
||||
var window = new Window { Content = bar };
|
||||
window.Show();
|
||||
|
||||
var message = bar.GetVisualDescendants()
|
||||
.OfType<SelectableTextBlock>()
|
||||
.FirstOrDefault();
|
||||
|
||||
Assert.NotNull(message);
|
||||
Assert.Equal("Échec de la suppression : boom", message!.Text);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -370,20 +420,105 @@ public class YavscApiClientTests
|
|||
}
|
||||
}
|
||||
|
||||
// SKIPPED — see comment.
|
||||
//
|
||||
// We can't cover "TrySilentLoginAsync purges the store when the
|
||||
// refresh token is rejected" with OidcStubAuthority: the stub's
|
||||
// /connect/token endpoint is unconditional and hands out a fresh
|
||||
// refresh token regardless of what the caller sends. To exercise
|
||||
// the RefreshFailedException path we'd need an authority option
|
||||
// to fail on a specific refresh-token string; until then the
|
||||
// production refresh-failure path is covered manually (and by
|
||||
// the structural guarantee that _store.Clear() runs in the catch
|
||||
// block of ForceRefreshAsync when result.IsError).
|
||||
//
|
||||
// [Fact]
|
||||
// public async Task TrySilentLoginAsync_purges_store_when_refresh_fails_permanently() { ... }
|
||||
[Fact]
|
||||
public async Task Concurrent_silent_login_and_api_calls_share_one_refresh()
|
||||
{
|
||||
using var authority = await OIDCStubAuthority.StartAsync();
|
||||
using var apiServer = new StubApiServer();
|
||||
await apiServer.StartAsync();
|
||||
var settings = BuildSettings(authority, apiServer.BaseUrl);
|
||||
var tokensPath = TokensPath();
|
||||
try
|
||||
{
|
||||
await using var original = await LoginAndPersistAsync(settings, authority, tokensPath);
|
||||
ExpireCachedAccessToken(tokensPath);
|
||||
var store = new TokenStore(tokensPath);
|
||||
var previousRefreshToken = store.Load()!.RefreshToken;
|
||||
await using var client = new YavscApiClient(settings, store);
|
||||
client.Http.BaseAddress = new Uri(settings.ApiUrl);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var silentLogins = Enumerable.Range(0, 8)
|
||||
.Select(_ => client.TrySilentLoginAsync(null, ct)).ToArray();
|
||||
var calls = Enumerable.Range(0, 8)
|
||||
.Select(_ => client.CallAsync<List<StubApiServer.Post>>(HttpMethod.Get, "posts", ct)).ToArray();
|
||||
await Task.WhenAll(silentLogins.Cast<Task>().Concat(calls));
|
||||
|
||||
Assert.All(silentLogins, login => Assert.True(login.Result));
|
||||
Assert.All(calls, call => Assert.NotEmpty(call.Result));
|
||||
Assert.Equal(1, authority.RefreshRequestCount);
|
||||
Assert.NotEqual(previousRefreshToken, store.Load()!.RefreshToken);
|
||||
Assert.True(client.HasValidSession);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tokensPath)) File.Delete(tokensPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_401_responses_share_one_refresh()
|
||||
{
|
||||
using var authority = await OIDCStubAuthority.StartAsync();
|
||||
using var apiServer = new StubApiServer();
|
||||
await apiServer.StartAsync();
|
||||
var settings = BuildSettings(authority, apiServer.BaseUrl);
|
||||
var tokensPath = TokensPath();
|
||||
try
|
||||
{
|
||||
await using var client = await LoginAndPersistAsync(settings, authority, tokensPath);
|
||||
apiServer.RejectedBearer = $"Bearer {client.CurrentAccessToken}";
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var calls = Enumerable.Range(0, 8)
|
||||
.Select(_ => client.CallAsync<List<StubApiServer.Post>>(HttpMethod.Get, "posts", ct)).ToArray();
|
||||
await Task.WhenAll(calls);
|
||||
|
||||
Assert.All(calls, call => Assert.NotEmpty(call.Result));
|
||||
Assert.Equal(1, authority.RefreshRequestCount);
|
||||
Assert.True(client.HasValidSession);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tokensPath)) File.Delete(tokensPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallAsync_reports_expired_session_and_clears_rejected_refresh_token()
|
||||
{
|
||||
using var authority = await OIDCStubAuthority.StartAsync();
|
||||
using var apiServer = new StubApiServer();
|
||||
await apiServer.StartAsync();
|
||||
var settings = BuildSettings(authority, apiServer.BaseUrl);
|
||||
var tokensPath = TokensPath();
|
||||
try
|
||||
{
|
||||
await using var original = await LoginAndPersistAsync(settings, authority, tokensPath);
|
||||
var store = new TokenStore(tokensPath);
|
||||
var tokens = store.Load()!;
|
||||
store.Save(tokens with
|
||||
{
|
||||
RefreshToken = "revoked-refresh-token",
|
||||
AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-5)
|
||||
});
|
||||
await using var client = new YavscApiClient(settings, store);
|
||||
client.Http.BaseAddress = new Uri(settings.ApiUrl);
|
||||
|
||||
var error = await Assert.ThrowsAsync<RefreshFailedException>(() =>
|
||||
client.CallAsync<JsonElement>(HttpMethod.Get, "posts", TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.True(error.IsPermanent);
|
||||
Assert.Contains("reconnecter", error.Message);
|
||||
Assert.False(client.HasValidSession);
|
||||
Assert.Null(store.Load());
|
||||
Assert.Equal(0, apiServer.RequestCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tokensPath)) File.Delete(tokensPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static T Last<T>(System.Collections.Generic.List<T> list)
|
||||
{
|
||||
|
|
@ -467,6 +602,7 @@ internal sealed class StubApiServer : IAsyncDisposable, IDisposable
|
|||
public string BaseUrl { get; private set; } = string.Empty;
|
||||
public List<string> SeenBearers { get; } = new();
|
||||
public int RequestCount => _requestCount;
|
||||
public string? RejectedBearer { get; set; }
|
||||
|
||||
public StubApiServer(bool forceFirstRequest = false)
|
||||
{
|
||||
|
|
@ -499,7 +635,8 @@ internal sealed class StubApiServer : IAsyncDisposable, IDisposable
|
|||
if (!string.IsNullOrEmpty(auth))
|
||||
SeenBearers.Add(auth!);
|
||||
|
||||
if (_forceFirstRequest && _requestCount == 1)
|
||||
if ((_forceFirstRequest && _requestCount == 1)
|
||||
|| (RejectedBearer is not null && auth == RejectedBearer))
|
||||
{
|
||||
ctx.Response.StatusCode = 401;
|
||||
ctx.Response.Close();
|
||||
|
|
|
|||
|
|
@ -11,5 +11,6 @@
|
|||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
|
|
|
|||
|
|
@ -6,17 +6,26 @@ using Avalonia.Controls;
|
|||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using PostIt.Helpers;
|
||||
using System.Globalization;
|
||||
using Live.Avalonia;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using ReactiveUI;
|
||||
using System.Reactive;
|
||||
|
||||
namespace PostIt;
|
||||
|
||||
public partial class App : Application
|
||||
public partial class App : Application, ILiveView, IVMPusherApp
|
||||
{
|
||||
public static bool LiveMode { get; private set; }
|
||||
private int _bootStarted;
|
||||
public static string? CliConfigFileSpecification { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// DI container the platform entry points hand to ViewModels so
|
||||
|
|
@ -41,43 +50,155 @@ public partial class App : Application
|
|||
{
|
||||
if (TryHandOffCustomSchemeUrl()) return;
|
||||
|
||||
this.ServiceProvider = new ServiceCollection().BuildServices();
|
||||
this.ServiceProvider = new ServiceCollection().BuildPostItServices();
|
||||
var settings = ServiceProvider.GetRequiredService<Settings>();
|
||||
|
||||
if (!LiveMode)
|
||||
{
|
||||
// Debugging requires pdb loading etc, so we disable live reloading
|
||||
// during a test run with an attached debugger.
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var window = ServiceProvider.GetRequiredService<MainWindow>();
|
||||
desktop.MainWindow = window;
|
||||
View = window.MainView;
|
||||
this.ConfigureRootView(window.MainView);
|
||||
|
||||
ApplyDarkMode(settings);
|
||||
InitializeClassicDesktopLifetime(settings, desktop);
|
||||
}
|
||||
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
|
||||
{
|
||||
singleViewFactoryApplicationLifetime.MainViewFactory =
|
||||
() =>
|
||||
{
|
||||
View = ServiceProvider.GetRequiredService<MainView>();
|
||||
this.ConfigureRootView(View);
|
||||
ApplyDarkMode(settings);
|
||||
return View;
|
||||
};
|
||||
InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime);
|
||||
}
|
||||
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
|
||||
{
|
||||
singleViewPlatform.MainView = View = ServiceProvider.GetRequiredService<MainView>();
|
||||
ConfigureRootView(View);
|
||||
ApplyDarkMode(settings);
|
||||
InitializeSingleViewLifetime(settings, singleViewPlatform);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Here, we create a new LiveViewHost, located in the 'Live.Avalonia'
|
||||
// namespace, and pass an ILiveView implementation to it. The ILiveView
|
||||
// implementation should have a parameterless constructor! Next, we
|
||||
// start listening for any changes in the source files. And then, we
|
||||
// show the LiveViewHost window. Simple enough, huh?
|
||||
var window = new LiveViewHost(this, Console.WriteLine);
|
||||
var view = ServiceProvider!.GetRequiredService<MainView>();
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
InitializeClassicDesktopLifetime(window, view, settings, desktop);
|
||||
}
|
||||
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
|
||||
{
|
||||
InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime);
|
||||
}
|
||||
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
|
||||
{
|
||||
InitializeSingleViewLifetime(settings, singleViewPlatform);
|
||||
}
|
||||
|
||||
window.StartWatchingSourceFilesForHotReloading();
|
||||
Console.WriteLine("LiveViewHost started for hot reloading.");
|
||||
window.Show();
|
||||
}
|
||||
|
||||
|
||||
#if DEBUG
|
||||
// Here we subscribe to ReactiveUI default exception handler to avoid app
|
||||
// termination in case if we do something wrong in our view models. See:
|
||||
// https://www.reactiveui.net/docs/handbook/default-exception-handler/
|
||||
//
|
||||
// In case if you are using another MV* framework, please refer to its
|
||||
// documentation explaining global exception handling.
|
||||
Console.WriteLine("Debug mode: ReactiveUI default exception handler is set.");
|
||||
RxApp.DefaultExceptionHandler = Observer.Create<Exception>(Console.WriteLine);
|
||||
#endif
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private void ConfigureRootView(MainView rootView)
|
||||
{
|
||||
// Déclencher le Boot une seule fois lors du chargement du contrôle à l'écran.
|
||||
rootView.AttachedToVisualTree += async (_, _) => await BootOnceAsync();
|
||||
private bool IsProduction()
|
||||
{
|
||||
#if DEBUG
|
||||
return false;
|
||||
#else
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
public async Task<Page> PushPageAsync(ViewModelBase viewModel)
|
||||
{
|
||||
var template = DataTemplates.FirstOrDefault(t => t.Match(viewModel));
|
||||
if (template is null)
|
||||
{
|
||||
throw new InvalidOperationException($"No IDataTemplate found for {viewModel.GetType().Name}.");
|
||||
}
|
||||
|
||||
Control? view = template.Build(viewModel) ;
|
||||
if (view is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Template for {viewModel.GetType().Name} returned <null>.");
|
||||
}
|
||||
|
||||
var page = view as Page;
|
||||
if (page is null)
|
||||
{
|
||||
// NavigationPage expects Page instances. Wrap any fallback control
|
||||
// (e.g. ViewLocator error TextBlock) into a ContentPage so it can render.
|
||||
page = new ContentPage { Content = view };
|
||||
}
|
||||
|
||||
page.DataContext = viewModel;
|
||||
|
||||
MainView? mainView = (Current as App).View;
|
||||
|
||||
// Avoid stacking the same singleton page twice (e.g. SettingsPage).
|
||||
var stack = mainView.NavRoot.NavigationStack;
|
||||
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
|
||||
{
|
||||
return page;
|
||||
}
|
||||
|
||||
await mainView.NavRoot.PushAsync(page);
|
||||
return page;
|
||||
}
|
||||
|
||||
public void InitializeSingleViewLifetime(Settings settings, ISingleViewApplicationLifetime singleViewPlatform)
|
||||
{
|
||||
singleViewPlatform.MainView = View = ServiceProvider!.GetRequiredService<MainView>();
|
||||
ConfigureRootView(View);
|
||||
ApplyStettings(settings);
|
||||
}
|
||||
|
||||
public void InitializeActivityLifetime(Settings settings, IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
|
||||
{
|
||||
singleViewFactoryApplicationLifetime.MainViewFactory =
|
||||
() =>
|
||||
{
|
||||
this.ConfigureRootView(ServiceProvider!.GetRequiredService<MainView>());
|
||||
ApplyStettings(settings);
|
||||
return View!;
|
||||
};
|
||||
}
|
||||
|
||||
public void InitializeClassicDesktopLifetime(Settings settings, IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var window = ServiceProvider!.GetRequiredService<MainWindow>();
|
||||
var mainView = (MainView)CreateView(window);
|
||||
InitializeClassicDesktopLifetime(window, mainView, settings, desktop);
|
||||
}
|
||||
|
||||
public void InitializeClassicDesktopLifetime(Window window, MainView mainView, Settings settings, IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
this.ConfigureRootView(mainView);
|
||||
ApplyStettings(settings);
|
||||
desktop.MainWindow = window;
|
||||
window.Content = mainView;
|
||||
window.Show();
|
||||
}
|
||||
|
||||
public void ConfigureRootView(MainView rootView)
|
||||
{
|
||||
rootView.AttachedToVisualTree += (_, _) =>
|
||||
Dispatcher.UIThread.Post(() => _ = BootOnceAsync());
|
||||
|
||||
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
|
||||
sessionStatus.LogoutCompleted += () =>
|
||||
|
|
@ -85,19 +206,11 @@ private void ConfigureRootView(MainView rootView)
|
|||
// Remplacer Window.NavRoot par rootView.NavRoot
|
||||
rootView.NavRoot.PopToRootAsync();
|
||||
};
|
||||
if (rootView.DataContext==null)
|
||||
rootView.DataContext = ServiceProvider!.GetRequiredService<HomePageViewModel>();
|
||||
|
||||
rootView.SessionBanner.DataContext = sessionStatus;
|
||||
}
|
||||
|
||||
private async Task BootOnceAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _bootStarted, 1) == 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
|
||||
await BootAsync(this.ServiceProvider!, api);
|
||||
this.View = rootView;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -111,12 +224,26 @@ private void ConfigureRootView(MainView rootView)
|
|||
View = mainView ?? throw new ArgumentNullException(nameof(mainView));
|
||||
}
|
||||
|
||||
private static void ApplyDarkMode(Settings settings)
|
||||
private static void ApplyStettings(Settings settings)
|
||||
{
|
||||
Application.Current!.RequestedThemeVariant =
|
||||
if (!string.IsNullOrWhiteSpace(settings.SelectedLanguage))
|
||||
Yavsc.Lang.Resources.Culture = new CultureInfo(settings.SelectedLanguage);
|
||||
Current!.RequestedThemeVariant =
|
||||
settings.DarkMode ? ThemeVariant.Dark : ThemeVariant.Light;
|
||||
}
|
||||
|
||||
|
||||
private async Task BootOnceAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _bootStarted, 1) == 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
|
||||
await BootAsync(this.ServiceProvider!, api);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run once after the main window is shown: try to refresh the
|
||||
/// cached OIDC tokens silently; on success, push MainPage on top
|
||||
|
|
@ -132,9 +259,9 @@ private void ConfigureRootView(MainView rootView)
|
|||
var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true);
|
||||
var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>();
|
||||
sessionStatus.Refresh();
|
||||
var homePage = provider.GetRequiredService<HomePageViewModel>();
|
||||
var homePageVm = provider.GetRequiredService<HomePageViewModel>();
|
||||
var app = (App)Current!;
|
||||
await app.PushPageAsync(homePage);
|
||||
await app.PushPageAsync(homePageVm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -148,7 +275,7 @@ private void ConfigureRootView(MainView rootView)
|
|||
public static async Task PushBlogsPageAsync()
|
||||
{
|
||||
var app = (App)Current!;
|
||||
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();
|
||||
var mainVm = app.ServiceProvider!.GetRequiredService<BlogsViewModel>();
|
||||
await mainVm.InitializeAsync();
|
||||
await app.PushPageAsync(mainVm);
|
||||
}
|
||||
|
|
@ -189,4 +316,32 @@ private void ConfigureRootView(MainView rootView)
|
|||
{
|
||||
await View!.NavRoot.PopAsync();
|
||||
}
|
||||
|
||||
internal static void UseConfigFileWhenLoading(string configFile)
|
||||
{
|
||||
CliConfigFileSpecification = configFile;
|
||||
}
|
||||
|
||||
internal static void EnableLiveMode()
|
||||
{
|
||||
LiveMode = true;
|
||||
}
|
||||
|
||||
public object CreateView(Window window)
|
||||
{
|
||||
if (this.ServiceProvider == null)
|
||||
{
|
||||
var serviceCollection = new ServiceCollection();
|
||||
this.ServiceProvider = serviceCollection.BuildPostItServices();
|
||||
}
|
||||
if (window.DataContext == null)
|
||||
window.DataContext = ServiceProvider!.GetRequiredService<HomePageViewModel>();
|
||||
|
||||
// The AppView class will inherit the DataContext
|
||||
// of the window. The AppView class can be a
|
||||
// UserControl, a Grid, a TextBlock, whatever.
|
||||
this.View = ServiceProvider!.GetRequiredService<MainView>();
|
||||
this.ConfigureRootView(this.View);
|
||||
return this.View;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
src/PostIt/PostIt/CLIOptions/CLIOptions.cs
Normal file
42
src/PostIt/PostIt/CLIOptions/CLIOptions.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Mono.Options;
|
||||
using PostIt;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.CLIOptions;
|
||||
|
||||
public static class CLIOptionHandler
|
||||
{
|
||||
public static void HandleArgs(string[] args)
|
||||
{
|
||||
bool showHelp = false;
|
||||
string configFile = null;
|
||||
bool liveMode = false;
|
||||
|
||||
var options = new OptionSet {
|
||||
{ "c|configuration=", "Configuration file to load", (string v) => configFile = v },
|
||||
{ "l|live", "Enable live mode", v => liveMode = true },
|
||||
{ "v|version", "Show the application version", v => Console.WriteLine("PostIt version "
|
||||
+ typeof(App).Assembly.GetName().Version?.ToString()) },
|
||||
{ "h|help", "Show this message and exit", v => showHelp = true }
|
||||
};
|
||||
|
||||
List<string> extra = options.Parse(args);
|
||||
|
||||
if (showHelp)
|
||||
{
|
||||
Console.WriteLine("Usage: app [OPTIONS]+");
|
||||
options.WriteOptionDescriptions(Console.Out);
|
||||
return;
|
||||
}
|
||||
if (configFile != null)
|
||||
{
|
||||
App.UseConfigFileWhenLoading(configFile);
|
||||
}
|
||||
if (liveMode)
|
||||
{
|
||||
App.EnableLiveMode();
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml
Normal file
56
src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<UserControl
|
||||
x:Class="PostIt.Controls.MarkdownEditorControl"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
|
||||
xmlns:md="using:MarkdownViewer.Core.Controls"
|
||||
x:Name="Root"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
|
||||
<Border
|
||||
BorderBrush="LightGray"
|
||||
BorderThickness="1"
|
||||
CornerRadius="5"
|
||||
IsVisible="{Binding !IsPreviewVisible, ElementName=Root}"
|
||||
Padding="0">
|
||||
<AvaloniaEdit:TextEditor
|
||||
x:Name="SourceEditor"
|
||||
Document="{Binding Document, ElementName=Root, Mode=TwoWay}"
|
||||
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
|
||||
ShowLineNumbers="True"
|
||||
WordWrap="True"
|
||||
BorderThickness="0"
|
||||
Padding="5,5,5,15"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
TextInputOptions.Multiline="True"
|
||||
TextInputOptions.ReturnKeyType="Default" />
|
||||
</Border>
|
||||
|
||||
<Border
|
||||
BorderBrush="LightGray"
|
||||
BorderThickness="1"
|
||||
CornerRadius="5"
|
||||
IsVisible="{Binding IsPreviewVisible, ElementName=Root}"
|
||||
Padding="8">
|
||||
<ScrollViewer
|
||||
x:Name="PreviewScrollViewer"
|
||||
BringIntoViewOnFocusChange="False"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
HorizontalScrollBarVisibility="Auto">
|
||||
<md:MarkdownViewer
|
||||
x:Name="PreviewViewer"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
VerticalAlignment="Top"
|
||||
ClipToBounds="False"
|
||||
UseLayoutRounding="True"
|
||||
MarkdownText="{Binding MarkdownText, ElementName=Root}" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
63
src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml.cs
Normal file
63
src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using AvaloniaEdit.Document;
|
||||
|
||||
namespace PostIt.Controls;
|
||||
|
||||
public partial class MarkdownEditorControl : UserControl
|
||||
{
|
||||
public static readonly StyledProperty<string?> MarkdownTextProperty =
|
||||
AvaloniaProperty.Register<MarkdownEditorControl, string?>(nameof(MarkdownText));
|
||||
|
||||
public static readonly StyledProperty<TextDocument?> DocumentProperty =
|
||||
AvaloniaProperty.Register<MarkdownEditorControl, TextDocument?>(nameof(Document));
|
||||
|
||||
public static readonly StyledProperty<bool> IsPreviewVisibleProperty =
|
||||
AvaloniaProperty.Register<MarkdownEditorControl, bool>(nameof(IsPreviewVisible), false);
|
||||
|
||||
public string? MarkdownText
|
||||
{
|
||||
get => GetValue(MarkdownTextProperty);
|
||||
set => SetValue(MarkdownTextProperty, value);
|
||||
}
|
||||
|
||||
public TextDocument? Document
|
||||
{
|
||||
get => GetValue(DocumentProperty);
|
||||
set => SetValue(DocumentProperty, value);
|
||||
}
|
||||
|
||||
public bool IsPreviewVisible
|
||||
{
|
||||
get => GetValue(IsPreviewVisibleProperty);
|
||||
set => SetValue(IsPreviewVisibleProperty, value);
|
||||
}
|
||||
|
||||
public MarkdownEditorControl()
|
||||
{
|
||||
InitializeComponent();
|
||||
PropertyChanged += OnPropertyChanged;
|
||||
}
|
||||
|
||||
private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.Property == MarkdownTextProperty)
|
||||
{
|
||||
var text = e.NewValue as string ?? string.Empty;
|
||||
if (Document is null || !string.Equals(Document.Text, text, StringComparison.Ordinal))
|
||||
{
|
||||
Document = new TextDocument(text);
|
||||
}
|
||||
}
|
||||
else if (e.Property == DocumentProperty)
|
||||
{
|
||||
var newDocument = e.NewValue as TextDocument;
|
||||
var text = newDocument?.Text ?? string.Empty;
|
||||
if (!string.Equals(MarkdownText, text, StringComparison.Ordinal))
|
||||
{
|
||||
MarkdownText = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,21 @@ public class SignaturePadControl : TemplatedControl
|
|||
set => SetValue(CaptureAreaProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When true the control ignores pointer input — it only displays
|
||||
/// strokes loaded into its buffer (e.g. the other party's
|
||||
/// signature, which must be shown but not editable). <see cref="Load"/>
|
||||
/// and <see cref="Clear"/> still work.
|
||||
/// </summary>
|
||||
public static readonly StyledProperty<bool> IsReadOnlyProperty =
|
||||
AvaloniaProperty.Register<SignaturePadControl, bool>(nameof(IsReadOnly));
|
||||
|
||||
public bool IsReadOnly
|
||||
{
|
||||
get => GetValue(IsReadOnlyProperty);
|
||||
set => SetValue(IsReadOnlyProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captured strokes in wire form. Exposed as a read-only view
|
||||
/// over the internal buffer. The buffer only mutates on the UI
|
||||
|
|
@ -109,6 +124,7 @@ public class SignaturePadControl : TemplatedControl
|
|||
|
||||
private void OnCapturePressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (IsReadOnly) return;
|
||||
if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return;
|
||||
e.Pointer.Capture(CaptureArea);
|
||||
_capturing = true;
|
||||
|
|
@ -181,6 +197,38 @@ public class SignaturePadControl : TemplatedControl
|
|||
RedrawRequested?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace the buffer with the given wire-format strokes — e.g. a
|
||||
/// signature loaded back from the server — and raise
|
||||
/// <see cref="RedrawRequested"/> so the host view repaints the ink.
|
||||
/// Loaded strokes are all sealed (there is no pending stroke).
|
||||
/// Malformed input (a non-positive length prefix, or a stroke that
|
||||
/// runs past the end of the array) is truncated at the offending
|
||||
/// point so the buffer never holds an inconsistent state.
|
||||
/// </summary>
|
||||
public void Load(int[] strokes)
|
||||
{
|
||||
if (strokes is null) throw new ArgumentNullException(nameof(strokes));
|
||||
|
||||
_strokes.Clear();
|
||||
_pendingPoints = 0;
|
||||
_capturing = false;
|
||||
|
||||
int i = 0;
|
||||
while (i < strokes.Length)
|
||||
{
|
||||
int k = strokes[i];
|
||||
if (k <= 0) break;
|
||||
if (i + 1 + 2 * k > strokes.Length) break;
|
||||
_strokes.Add(k);
|
||||
for (int p = 0; p < 2 * k; p++)
|
||||
_strokes.Add(strokes[i + 1 + p]);
|
||||
i += 1 + 2 * k;
|
||||
}
|
||||
|
||||
RedrawRequested?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defensive copy of the current buffer wrapped in a
|
||||
/// <see cref="SignaturePadData"/>. Cheap; call only when the
|
||||
|
|
|
|||
28
src/PostIt/PostIt/Controls/SignaturePadView.axaml
Normal file
28
src/PostIt/PostIt/Controls/SignaturePadView.axaml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:PostIt.Controls"
|
||||
x:Class="PostIt.Controls.SignaturePadView"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<!--
|
||||
Capture surface. The SignaturePadControl is render-agnostic,
|
||||
so we host it inside a Border that is the capture area, and
|
||||
overlay a Canvas for the visual feedback. The code-behind
|
||||
repaints the canvas on every RedrawRequested. The Border size
|
||||
is driven by the CaptureWidth / CaptureHeight styled properties.
|
||||
-->
|
||||
<Border x:Name="PadFrame"
|
||||
BorderBrush="Black"
|
||||
BorderThickness="1"
|
||||
Background="White"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
ClipToBounds="True">
|
||||
<Grid>
|
||||
<controls:SignaturePadControl x:Name="InnerPad" />
|
||||
<Canvas x:Name="InkLayer"
|
||||
Background="Transparent"
|
||||
IsHitTestVisible="False" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
206
src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs
Normal file
206
src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Shapes;
|
||||
using Avalonia.Media;
|
||||
using PostIt.Models;
|
||||
|
||||
namespace PostIt.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Reusable signature-capture surface: wraps the render-agnostic
|
||||
/// <see cref="SignaturePadControl"/> in a sized <see cref="Border"/>
|
||||
/// (the hit-test region) with an overlay <see cref="Canvas"/> that
|
||||
/// the code-behind repaints from the control's stroke buffer. Owns
|
||||
/// the ink-rendering logic so every page that captures a signature
|
||||
/// (the dev-only <c>SignaturePage</c>, the estimate validation page,
|
||||
/// ...) shares one implementation.
|
||||
///
|
||||
/// Hosts bind <see cref="CaptureWidth"/>/<see cref="CaptureHeight"/>
|
||||
/// (DIPs, defaults 600×200) to size the surface. View models attach
|
||||
/// to the inner <see cref="SignaturePad"/> control (exposed here) to
|
||||
/// read snapshots, drive <c>Clear</c>, and subscribe to
|
||||
/// <see cref="SignaturePadControl.StrokeCompleted"/> /
|
||||
/// <see cref="SignaturePadControl.RedrawRequested"/> — the same
|
||||
/// contract the headless tests use, since a <see cref="UserControl"/>
|
||||
/// needs its XAML loaded to be drivable.
|
||||
/// </summary>
|
||||
public partial class SignaturePadView : UserControl
|
||||
{
|
||||
private static readonly IBrush StrokeBrush = new SolidColorBrush(Color.FromRgb(0x10, 0x10, 0x10));
|
||||
private const double StrokeThickness = 2.0;
|
||||
private const double CoordinateMax = 10_000.0;
|
||||
|
||||
// Last size the ink layer was painted at. LayoutUpdated repaints
|
||||
// only when this changes, so adding/removing polyline children
|
||||
// (which invalidates layout) does not loop.
|
||||
private Size _lastPaintedSize;
|
||||
|
||||
public static readonly StyledProperty<double> CaptureWidthProperty =
|
||||
AvaloniaProperty.Register<SignaturePadView, double>(nameof(CaptureWidth), 600);
|
||||
|
||||
public static readonly StyledProperty<double> CaptureHeightProperty =
|
||||
AvaloniaProperty.Register<SignaturePadView, double>(nameof(CaptureHeight), 200);
|
||||
|
||||
public double CaptureWidth
|
||||
{
|
||||
get => GetValue(CaptureWidthProperty);
|
||||
set => SetValue(CaptureWidthProperty, value);
|
||||
}
|
||||
|
||||
public double CaptureHeight
|
||||
{
|
||||
get => GetValue(CaptureHeightProperty);
|
||||
set => SetValue(CaptureHeightProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The underlying capture control. View models attach to this to
|
||||
/// read <see cref="SignaturePadControl.Snapshot"/> / call
|
||||
/// <see cref="SignaturePadControl.Clear"/> and subscribe to its
|
||||
/// events; this view repaints the ink layer from the same buffer.
|
||||
/// </summary>
|
||||
public SignaturePadControl SignaturePad => InnerPad;
|
||||
|
||||
public SignaturePadView()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// Wire the capture area: the Pad is the control, the
|
||||
// surrounding Border (PadFrame) is the hit-test region. For an
|
||||
// inline control with no template, capture wiring happens on
|
||||
// first measure, which is guaranteed before the user can
|
||||
// interact, so attaching here is safe.
|
||||
InnerPad.CaptureArea = PadFrame;
|
||||
ApplyFrameSize();
|
||||
|
||||
InnerPad.RedrawRequested += OnPadRedrawRequested;
|
||||
// Repaint once layout completes so an ink buffer loaded before
|
||||
// the first arrange (e.g. an existing signature on reopen)
|
||||
// still renders without waiting for a pointer event. The
|
||||
// handler repaints only on a size change to avoid a layout
|
||||
// loop (child changes invalidate layout → LayoutUpdated).
|
||||
LayoutUpdated += OnLayoutUpdated;
|
||||
}
|
||||
|
||||
private void OnLayoutUpdated(object? sender, EventArgs e)
|
||||
{
|
||||
var size = EffectivePadSize();
|
||||
if (size != _lastPaintedSize)
|
||||
Repaint();
|
||||
}
|
||||
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
if (change.Property == CaptureWidthProperty || change.Property == CaptureHeightProperty)
|
||||
{
|
||||
ApplyFrameSize();
|
||||
Repaint();
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFrameSize()
|
||||
{
|
||||
if (PadFrame is null) return;
|
||||
PadFrame.Width = Math.Max(1, CaptureWidth);
|
||||
PadFrame.Height = Math.Max(1, CaptureHeight);
|
||||
}
|
||||
|
||||
private void OnPadRedrawRequested(object? sender, EventArgs e) => Repaint();
|
||||
|
||||
/// <summary>Defensive copy of the current buffer.</summary>
|
||||
public SignaturePadData Snapshot() => InnerPad.Snapshot();
|
||||
|
||||
/// <summary>Forget every captured stroke.</summary>
|
||||
public void Clear() => InnerPad.Clear();
|
||||
|
||||
/// <summary>Wire-format strokes, forwarded from the inner control.</summary>
|
||||
public IReadOnlyList<int> Strokes => InnerPad.Strokes;
|
||||
|
||||
/// <summary>
|
||||
/// The effective ink surface size: the arranged bounds when
|
||||
/// available, otherwise the explicitly sized
|
||||
/// <see cref="CaptureWidth"/>/<see cref="CaptureHeight"/>. The
|
||||
/// latter covers a repaint triggered before the first layout pass
|
||||
/// (e.g. loading an existing signature on reopen), when Bounds is
|
||||
/// still empty.
|
||||
/// </summary>
|
||||
private Size EffectivePadSize()
|
||||
{
|
||||
var w = PadFrame.Bounds.Width > 0 ? PadFrame.Bounds.Width : PadFrame.Width;
|
||||
var h = PadFrame.Bounds.Height > 0 ? PadFrame.Bounds.Height : PadFrame.Height;
|
||||
if (w <= 0 || double.IsNaN(w) || h <= 0 || double.IsNaN(h))
|
||||
return default;
|
||||
return new Size(w, h);
|
||||
}
|
||||
|
||||
private void Repaint()
|
||||
{
|
||||
if (InkLayer is null) return;
|
||||
|
||||
var size = EffectivePadSize();
|
||||
if (size == default) return;
|
||||
_lastPaintedSize = size;
|
||||
|
||||
InkLayer.Children.Clear();
|
||||
var w = size.Width;
|
||||
var h = size.Height;
|
||||
|
||||
var pending = InnerPad.PendingStroke;
|
||||
var strokes = InnerPad.Strokes;
|
||||
int sealedCount = strokes.Count - pending.Count;
|
||||
if (sealedCount < 0)
|
||||
{
|
||||
sealedCount = 0;
|
||||
}
|
||||
|
||||
int i = 0;
|
||||
while (i < sealedCount)
|
||||
{
|
||||
int k = strokes[i];
|
||||
if (k <= 0) break;
|
||||
i++; // skip the length prefix
|
||||
|
||||
var poly = new Polyline
|
||||
{
|
||||
Stroke = StrokeBrush,
|
||||
StrokeThickness = StrokeThickness,
|
||||
StrokeLineCap = PenLineCap.Round,
|
||||
StrokeJoin = PenLineJoin.Round,
|
||||
};
|
||||
var pts = new List<Point>(k);
|
||||
for (int p = 0; p < k; p++)
|
||||
{
|
||||
int nx = strokes[i++];
|
||||
int ny = strokes[i++];
|
||||
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
|
||||
}
|
||||
poly.Points = pts;
|
||||
InkLayer.Children.Add(poly);
|
||||
}
|
||||
|
||||
if (pending.Count > 0)
|
||||
{
|
||||
var poly = new Polyline
|
||||
{
|
||||
Stroke = StrokeBrush,
|
||||
StrokeThickness = StrokeThickness,
|
||||
StrokeLineCap = PenLineCap.Round,
|
||||
StrokeJoin = PenLineJoin.Round,
|
||||
};
|
||||
|
||||
var pts = new List<Point>(pending.Count / 2);
|
||||
for (int p = 0; p < pending.Count; p += 2)
|
||||
{
|
||||
int nx = pending[p];
|
||||
int ny = pending[p + 1];
|
||||
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
|
||||
}
|
||||
|
||||
poly.Points = pts;
|
||||
InkLayer.Children.Add(poly);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,10 +13,16 @@
|
|||
FontWeight="Bold"
|
||||
Foreground="{Binding Foreground}"
|
||||
VerticalAlignment="Top"/>
|
||||
<TextBlock Text="{Binding Message}"
|
||||
<SelectableTextBlock Text="{Binding Message}"
|
||||
Foreground="{Binding Foreground}"
|
||||
TextWrapping="Wrap"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Content="{Binding CheckCommandLabel}"
|
||||
Foreground="{Binding Foreground}"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding HasCheckCommand}"
|
||||
Command="{Binding CheckCommand}"
|
||||
/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UserControl>
|
||||
|
|
|
|||
61
src/PostIt/PostIt/Helpers/FileSaveHelpers.cs
Normal file
61
src/PostIt/PostIt/Helpers/FileSaveHelpers.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
namespace PostIt.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Saves a downloaded byte payload to a file chosen by the user through
|
||||
/// the platform save picker. This is the first file-save (as opposed to
|
||||
/// file-open) helper in PostIt; the open picker is already used for blog
|
||||
/// attachment and avatar uploads
|
||||
/// (<see cref="Views.Blogs.BlogsPage"/>, <see cref="Views.Layout.SettingsPage"/>).
|
||||
///
|
||||
/// <para>The <c>TopLevel</c> hosting the picker is resolved from the
|
||||
/// application's root <c>MainView</c> (<c>App.View</c>), which works for
|
||||
/// both the desktop and single-view lifetimes.</para>
|
||||
/// </summary>
|
||||
public static class FileSaveHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Prompt the user to choose a destination and write
|
||||
/// <paramref name="content"/> to it. Returns false when the user
|
||||
/// cancels the picker; throws on IO/platform errors so the caller
|
||||
/// can surface them.
|
||||
/// </summary>
|
||||
public static async Task<bool> SaveAsync(
|
||||
string suggestedName,
|
||||
string defaultExtension,
|
||||
byte[] content,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var view = (Application.Current as App)?.View;
|
||||
var topLevel = view is null ? null : TopLevel.GetTopLevel(view);
|
||||
if (topLevel is null)
|
||||
throw new InvalidOperationException("Aucune fenêtre active pour enregistrer le fichier.");
|
||||
|
||||
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Enregistrer sous",
|
||||
DefaultExtension = defaultExtension,
|
||||
SuggestedFileName = suggestedName,
|
||||
FileTypeChoices = new[]
|
||||
{
|
||||
new FilePickerFileType($"{defaultExtension.ToUpper()} file")
|
||||
{
|
||||
Patterns = new[] { $"*.{defaultExtension}" }
|
||||
}
|
||||
}
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
if (file is null)
|
||||
return false; // user cancelled
|
||||
|
||||
await using var stream = await file.OpenWriteAsync().ConfigureAwait(true);
|
||||
await stream.WriteAsync(content, ct).ConfigureAwait(true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,10 @@ using System;
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.ViewModels.Chat;
|
||||
using PostIt.Views;
|
||||
using PostIt.Views.Blogs;
|
||||
using PostIt.Views.Chat;
|
||||
using PostIt.Views.Commands;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
|
|
@ -10,7 +13,7 @@ namespace PostIt.Helpers;
|
|||
|
||||
public static class ServiceCollectionHelpers
|
||||
{
|
||||
public static IServiceProvider BuildServices(this ServiceCollection services)
|
||||
public static IServiceProvider BuildPostItServices(this ServiceCollection services)
|
||||
{
|
||||
var settings = new Settings();
|
||||
settings.Load();
|
||||
|
|
@ -21,6 +24,7 @@ public static class ServiceCollectionHelpers
|
|||
|
||||
var api = new YavscApiClient(settings, tokenStore);
|
||||
var client = new BlogApiClient(api, settings.BlogsApiUrl);
|
||||
var userFilesClient = new UserFilesApiClient(api, settings.BlogsApiUrl);
|
||||
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
||||
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
||||
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
||||
|
|
@ -29,13 +33,21 @@ public static class ServiceCollectionHelpers
|
|||
() => settings.ApiUrl,
|
||||
() => settings.Authentication?.Authority);
|
||||
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
|
||||
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();
|
||||
|
||||
// Vues
|
||||
services.AddSingleton<MainView>();
|
||||
services.AddSingleton<MainPage>();
|
||||
services.AddSingleton<MainWindow>();
|
||||
services.AddTransient<MainWindow>();
|
||||
services.AddTransient<MainView>();
|
||||
services.AddTransient<BlogsPage>();
|
||||
|
||||
|
||||
// Models
|
||||
services.AddSingleton<ChatViewModel>();
|
||||
|
||||
// SettingsPage is a singleton: there must be one and only one
|
||||
// instance of the settings UI for the lifetime of the app.
|
||||
|
|
@ -49,15 +61,27 @@ public static class ServiceCollectionHelpers
|
|||
// the navigation stack, each bound to a fresh
|
||||
// SettingsViewModel and missing any in-flight edits.
|
||||
services.AddSingleton<SettingsPage>();
|
||||
services.AddSingleton<AboutPage>();
|
||||
services.AddSingleton<HomePage>();
|
||||
services.AddSingleton<SignaturePage>();
|
||||
services.AddSingleton<CirclesPage>();
|
||||
services.AddSingleton<ActivitiesPage>();
|
||||
services.AddTransient<CommandFormsPage>();
|
||||
services.AddTransient<ChatPage>();
|
||||
services.AddTransient<RdvPage>();
|
||||
services.AddTransient<BrushPage>();
|
||||
services.AddTransient<BillingQueriesPage>();
|
||||
services.AddTransient<BillingQueryDetailsPage>();
|
||||
services.AddTransient<ProviderOngoingRequestsPage>();
|
||||
services.AddTransient<EstimateEditionPage>();
|
||||
services.AddTransient<EstimateListPage>();
|
||||
services.AddTransient<EstimateValidationPage>();
|
||||
services.AddTransient<MyFilesPage>();
|
||||
services.AddTransient<UserProfilePage>();
|
||||
services.AddTransient<PerformerConfigurationPage>();
|
||||
services.AddTransient<AdministrationPage>();
|
||||
services.AddTransient<ResourceUsagePage>();
|
||||
|
||||
// ViewModels
|
||||
services.AddSingleton(settings);
|
||||
services.AddSingleton<YavscApiClient>(api);
|
||||
|
|
@ -67,9 +91,18 @@ public static class ServiceCollectionHelpers
|
|||
services.AddSingleton(userSearchClient);
|
||||
services.AddSingleton(activityClient);
|
||||
services.AddSingleton(billingClient);
|
||||
services.AddSingleton(estimateClient);
|
||||
services.AddSingleton(frontClient);
|
||||
services.AddSingleton(resourceUsageClient);
|
||||
services.AddSingleton(notificationsClient);
|
||||
services.AddSingleton(userFilesClient);
|
||||
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||
services.AddSingleton<AboutPageViewModel>();
|
||||
services.AddSingleton<HomePageViewModel>();
|
||||
services.AddTransient<UserProfilePageViewModel>();
|
||||
services.AddTransient<PerformerConfigurationPageViewModel>();
|
||||
services.AddTransient<AdministrationPageViewModel>();
|
||||
services.AddSingleton<SignaturePageViewModel>();
|
||||
services.AddSingleton<CirclesPageViewModel>();
|
||||
services.AddSingleton<ActivitiesPageViewModel>();
|
||||
|
|
@ -89,7 +122,7 @@ public static class ServiceCollectionHelpers
|
|||
sessionStatus.Refresh();
|
||||
services.AddSingleton(sessionStatus);
|
||||
services.AddSingleton<SessionStatusBanner>();
|
||||
services.AddSingleton<MainViewModel>();
|
||||
services.AddSingleton<BlogsViewModel>();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Helpers;
|
||||
|
||||
public static class ViewModelBaseHelpers
|
||||
{
|
||||
public static async Task PushPageAsync(this App app, ViewModelBase vm)
|
||||
{
|
||||
var window = app.View;
|
||||
if (window is null)
|
||||
{
|
||||
throw new InvalidOperationException("MainWindow is not initialized yet.");
|
||||
}
|
||||
|
||||
var template = app.DataTemplates.FirstOrDefault(t => t.Match(vm));
|
||||
if (template is null)
|
||||
{
|
||||
throw new InvalidOperationException($"No IDataTemplate found for {vm.GetType().Name}.");
|
||||
}
|
||||
|
||||
var view = template.Build(vm);
|
||||
if (view is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Template for {vm.GetType().Name} returned <null>.");
|
||||
}
|
||||
|
||||
var page = view as Page;
|
||||
if (page is null)
|
||||
{
|
||||
// NavigationPage expects Page instances. Wrap any fallback control
|
||||
// (e.g. ViewLocator error TextBlock) into a ContentPage so it can render.
|
||||
page = new ContentPage { Content = view };
|
||||
}
|
||||
|
||||
page.DataContext = vm;
|
||||
|
||||
// Avoid stacking the same singleton page twice (e.g. SettingsPage).
|
||||
var stack = window.NavRoot.NavigationStack;
|
||||
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await window.NavRoot.PushAsync(page);
|
||||
}
|
||||
}
|
||||
10
src/PostIt/PostIt/IVMPusherApp.cs
Normal file
10
src/PostIt/PostIt/IVMPusherApp.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
using System.Threading.Tasks;
|
||||
using PostIt.ViewModels;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace PostIt;
|
||||
|
||||
internal interface IVMPusherApp
|
||||
{
|
||||
Task<Page> PushPageAsync(ViewModelBase viewModel);
|
||||
}
|
||||
6
src/PostIt/PostIt/Interfaces/IPCMProcessor.cs
Normal file
6
src/PostIt/PostIt/Interfaces/IPCMProcessor.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
using System;
|
||||
|
||||
public interface IPCMProcessor : IDisposable
|
||||
{
|
||||
void Process(short[] audioFrame);
|
||||
}
|
||||
72
src/PostIt/PostIt/Lang/Resources.Designer.cs
generated
Normal file
72
src/PostIt/PostIt/Lang/Resources.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
// Runtime Version:4.0.30319.42000
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace Yavsc.Lang {
|
||||
using System;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A strongly-typed resource class, for looking up localized strings, etc.
|
||||
/// </summary>
|
||||
// This class was auto-generated by the StronglyTypedResourceBuilder
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// with the /str option, or rebuild your VS project.
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
public class Resources {
|
||||
|
||||
private static global::System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static global::System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
public Resources() {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached ResourceManager instance used by this class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
public static global::System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.ReferenceEquals(resourceMan, null)) {
|
||||
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Yavsc.Lang.Resources", typeof(Resources).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the current thread's CurrentUICulture property for all
|
||||
/// resource lookups using this strongly typed resource class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
public static global::System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Bienvenue dans PostIt !!!.
|
||||
/// </summary>
|
||||
public static string GreetingText {
|
||||
get {
|
||||
return ResourceManager.GetString("GreetingText", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/PostIt/PostIt/Lang/Resources.en-EN.resx
Normal file
5
src/PostIt/PostIt/Lang/Resources.en-EN.resx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<root>
|
||||
<data name="GreetingText">
|
||||
<value>Welcome to PostIt!</value>
|
||||
</data>
|
||||
</root>
|
||||
5
src/PostIt/PostIt/Lang/Resources.pt-PT.resx
Normal file
5
src/PostIt/PostIt/Lang/Resources.pt-PT.resx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<root>
|
||||
<data name="GreetingText">
|
||||
<value>Bemvindo em PostIt!</value>
|
||||
</data>
|
||||
</root>
|
||||
5
src/PostIt/PostIt/Lang/Resources.resx
Normal file
5
src/PostIt/PostIt/Lang/Resources.resx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<root>
|
||||
<data name="GreetingText">
|
||||
<value>Bienvenue dans PostIt !!!</value>
|
||||
</data>
|
||||
</root>
|
||||
15
src/PostIt/PostIt/Models/ChatMessage.cs
Normal file
15
src/PostIt/PostIt/Models/ChatMessage.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
namespace PostIt.ViewModels.Chat
|
||||
{
|
||||
/// <summary>
|
||||
/// DTO de message de salon, calqué sur la charge envoyée par
|
||||
/// <c>ChatHub.Send</c> côté serveur ({ Name, Room, Message }).
|
||||
/// Le protocole JSON de SignalR sérialise en camelCase ; la
|
||||
/// désérialisation est insensible à la casse par défaut.
|
||||
/// </summary>
|
||||
public class ChatMessage
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Room { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
}
|
||||
14
src/PostIt/PostIt/Models/ChatMessageDto.cs
Normal file
14
src/PostIt/PostIt/Models/ChatMessageDto.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
namespace PostIt.ViewModels.Chat
|
||||
{
|
||||
/// <summary>
|
||||
/// DTO minimal calqué sur l'objet anonyme émis par le hub
|
||||
/// (<c>{ Name, Room, Message }</c>) via l'événement
|
||||
/// <c>ReceiveMessage</c>.
|
||||
/// </summary>
|
||||
public class ChatMessageDto
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Room { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
}
|
||||
11
src/PostIt/PostIt/Models/ChatRoomInfo.cs
Normal file
11
src/PostIt/PostIt/Models/ChatRoomInfo.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace PostIt.ViewModels.Chat
|
||||
{
|
||||
// DTO minimal calqué sur votre logique serveur ChatRoomInfo
|
||||
public class ChatRoomInfo
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public List<string> Users { get; set; } = new();
|
||||
}
|
||||
}
|
||||
6
src/PostIt/PostIt/Models/IWithLabel.cs
Normal file
6
src/PostIt/PostIt/Models/IWithLabel.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace PostIt.ViewModels;
|
||||
|
||||
internal interface IWithLabel
|
||||
{
|
||||
string Label { get; }
|
||||
}
|
||||
8
src/PostIt/PostIt/Models/StatusSeverity.cs
Normal file
8
src/PostIt/PostIt/Models/StatusSeverity.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace PostIt.Models;
|
||||
|
||||
public enum StatusSeverity
|
||||
{
|
||||
Info,
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
|
|
@ -5,8 +5,10 @@
|
|||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
|
|
@ -23,20 +25,43 @@
|
|||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.AvaloniaEdit" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="LibVLCSharp.Avalonia" />
|
||||
<PackageReference Include="Live.Avalonia" />
|
||||
<PackageReference Include="Mapsui" />
|
||||
<PackageReference Include="Mapsui.Avalonia12" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="IdentityModel.OidcClient" />
|
||||
<PackageReference Include="MarkdownViewer.Core" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Mono.Options" />
|
||||
<PackageReference Include="PvRecorder" />
|
||||
<PackageReference Include="Silk.NET.OpenAL" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
|
||||
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Lang/Resources.resx">
|
||||
<Generator>PublicResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Lang/Resources.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Lang/Resources.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Watch Include="**\*.xaml" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
67
src/PostIt/PostIt/Services/AudioRecorder.cs
Normal file
67
src/PostIt/PostIt/Services/AudioRecorder.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using Pv;
|
||||
|
||||
public class AudioRecorder : IDisposable
|
||||
{
|
||||
private int frameLength;
|
||||
PvRecorder recorder;
|
||||
|
||||
Thread thread;
|
||||
short[] audioFrame;
|
||||
IPCMProcessor processor;
|
||||
|
||||
public bool processing { get; private set; }
|
||||
|
||||
public AudioRecorder(IPCMProcessor processor, int frameLength=512, int deviceIndex = -1)
|
||||
{
|
||||
this.frameLength = frameLength;
|
||||
this.recorder = PvRecorder.Create(frameLength: frameLength, deviceIndex: deviceIndex);
|
||||
this.audioFrame = new short[frameLength];
|
||||
this.processor=processor;
|
||||
}
|
||||
|
||||
public static string [] GetAvailableDevices()
|
||||
{
|
||||
return PvRecorder.GetAvailableDevices();
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
recorder.Start();
|
||||
thread = new Thread(ThreadLoop);
|
||||
thread.Start();
|
||||
}
|
||||
|
||||
public void ThreadLoop()
|
||||
{
|
||||
while (recorder.IsRecording)
|
||||
{
|
||||
if (thread.ThreadState != ThreadState.Running)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (processing)
|
||||
{
|
||||
Thread.Sleep(10);
|
||||
continue;
|
||||
}
|
||||
processing = true;
|
||||
audioFrame = recorder.Read();
|
||||
processor.Process(audioFrame);
|
||||
processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
thread.Interrupt();
|
||||
thread.Join();
|
||||
recorder.Stop();
|
||||
thread = null;
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
recorder.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
@ -49,6 +59,16 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
_bearer = new BearerTokenHandler(this);
|
||||
Http = new HttpClient(_bearer, disposeHandler: true);
|
||||
|
||||
// HTTP/3 (QUIC) vers les APIs Yavsc : le edge nginx termine h3 sur
|
||||
// tous les vhosts (listen 443 quic ; http3 on ; Alt-Svc h3=":443").
|
||||
// RequestVersionOrLower négocie h3 quand le serveur ET la plateforme
|
||||
// le permettent (Desktop avec libmsquic), et retombe sur h2/h1.1
|
||||
// sinon — notamment WASM, où le navigateur n'expose pas QUIC au
|
||||
// fetch, et Android si libmsquic n'est pas disponible. Tous les
|
||||
// clients Yavsc.Api.Client partagent cette même instance `Http`.
|
||||
Http.DefaultRequestVersion = new Version(3, 0);
|
||||
Http.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
|
||||
|
||||
_tokens = store.Load();
|
||||
}
|
||||
|
||||
|
|
@ -191,13 +211,52 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
object? body = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
|
||||
using var response = await SendAsync(method, path, body is null ? null : () => JsonContent.Create(body), ct).ConfigureAwait(false);
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
|
||||
var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
var dto = await JsonSerializer.DeserializeAsync<T>(stream,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct).ConfigureAwait(false);
|
||||
return dto!;
|
||||
return await ReadJsonAsync<T>(response, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call a multipart endpoint, transparently refreshing the token if needed.
|
||||
/// </summary>
|
||||
public virtual async Task<T> CallAsync<T>(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
Func<HttpContent> contentFactory,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (contentFactory is null)
|
||||
throw new ArgumentNullException(nameof(contentFactory));
|
||||
|
||||
using var response = await SendAsync(method, path, contentFactory, ct).ConfigureAwait(false);
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
|
||||
return await ReadJsonAsync<T>(response, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialise the response body as <typeparamref name="T"/>,
|
||||
/// tolerating an empty body. <c>PUT /blogspot/{id}</c> answers
|
||||
/// <c>204 No Content</c>: without this guard,
|
||||
/// <c>JsonSerializer.DeserializeAsync</c> throws
|
||||
/// <c>JsonException: The input does not contain any JSON
|
||||
/// tokens</c> and the VM surfaces a successful save as an
|
||||
/// error. Callers that genuinely need a payload
|
||||
/// (<c>GetPostAsync</c>, <c>CreatePostAsync</c>) are unaffected:
|
||||
/// their endpoints always return a body.
|
||||
/// </summary>
|
||||
private static async Task<T> ReadJsonAsync<T>(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||
return default!;
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
return default!;
|
||||
|
||||
return JsonSerializer.Deserialize<T>(payload,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -217,7 +276,23 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
object? body = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
|
||||
using var response = await SendAsync(method, path, body is null ? null : () => JsonContent.Create(body), ct).ConfigureAwait(false);
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call a multipart endpoint that returns no useful body (DELETE, etc.).
|
||||
/// </summary>
|
||||
public async Task CallAsync(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
Func<HttpContent> contentFactory,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (contentFactory is null)
|
||||
throw new ArgumentNullException(nameof(contentFactory));
|
||||
|
||||
using var response = await SendAsync(method, path, contentFactory, ct).ConfigureAwait(false);
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
|
@ -232,7 +307,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
=> CallAsync(method, path, body: null, ct);
|
||||
|
||||
private async Task<HttpResponseMessage> SendAsync(
|
||||
HttpMethod method, string path, object? body, CancellationToken ct)
|
||||
HttpMethod method, string path, Func<HttpContent>? contentFactory, CancellationToken ct)
|
||||
{
|
||||
if (_tokens is null)
|
||||
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
|
||||
|
|
@ -240,8 +315,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
using var req = new HttpRequestMessage(method, path);
|
||||
if (body is not null)
|
||||
req.Content = JsonContent.Create(body);
|
||||
if (contentFactory is not null)
|
||||
req.Content = contentFactory();
|
||||
var response = await Http.SendAsync(req, ct).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
|
|
@ -249,11 +324,11 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
// Server rejected: could be revocation, clock skew, audience mismatch.
|
||||
// Force a refresh and retry exactly once.
|
||||
response.Dispose();
|
||||
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
||||
await ForceRefreshAsync(ct, req.Headers.Authorization?.Parameter).ConfigureAwait(false);
|
||||
|
||||
using var retry = new HttpRequestMessage(method, path);
|
||||
if (body is not null)
|
||||
retry.Content = JsonContent.Create(body);
|
||||
if (contentFactory is not null)
|
||||
retry.Content = contentFactory();
|
||||
response = await Http.SendAsync(retry, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
|
@ -314,13 +389,46 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
|
||||
return;
|
||||
|
||||
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ForceRefreshAsync(CancellationToken ct, string? rejectedAccessToken = null)
|
||||
{
|
||||
await _refreshGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
|
||||
var tokens = _tokens;
|
||||
if (tokens is null || string.IsNullOrEmpty(tokens.RefreshToken))
|
||||
throw new InvalidOperationException("Session expirée. Veuillez vous reconnecter.");
|
||||
|
||||
if (rejectedAccessToken is null
|
||||
? tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew
|
||||
: !string.Equals(tokens.AccessToken, rejectedAccessToken, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
||||
var result = await _oidc.RefreshTokenAsync(tokens.RefreshToken, cancellationToken: ct).ConfigureAwait(false);
|
||||
if (result.IsError)
|
||||
{
|
||||
var isPermanent = result.Error is "invalid_grant" or "invalid_client";
|
||||
if (isPermanent)
|
||||
{
|
||||
_store.Clear();
|
||||
_tokens = null;
|
||||
}
|
||||
throw new RefreshFailedException(
|
||||
isPermanent
|
||||
? $"Session expirée. Veuillez vous reconnecter ({result.Error})."
|
||||
: $"Impossible de renouveler la session : {result.Error ?? "refresh failed"}.",
|
||||
isPermanent);
|
||||
}
|
||||
|
||||
_tokens = new RefreshTokenRecord(
|
||||
AccessToken: result.AccessToken!,
|
||||
RefreshToken: result.RefreshToken ?? tokens.RefreshToken,
|
||||
AccessTokenExpiresAt: ComputeExpiry(result.AccessTokenExpiration, result.AccessToken),
|
||||
IdToken: result.IdentityToken ?? tokens.IdToken);
|
||||
|
||||
_store.Save(_tokens);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -328,30 +436,6 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private async Task ForceRefreshAsync(CancellationToken ct)
|
||||
{
|
||||
if (_tokens is null || string.IsNullOrEmpty(_tokens.RefreshToken))
|
||||
throw new InvalidOperationException("No refresh token available.");
|
||||
|
||||
var result = await _oidc.RefreshTokenAsync(_tokens.RefreshToken, cancellationToken: ct).ConfigureAwait(false);
|
||||
if (result.IsError)
|
||||
{
|
||||
// Refresh token dead: revoked, expired, or rotation-theft
|
||||
// detected. Purge and force an interactive re-login.
|
||||
_store.Clear();
|
||||
_tokens = null;
|
||||
throw new RefreshFailedException(result.Error ?? "refresh failed", isPermanent: true);
|
||||
}
|
||||
|
||||
_tokens = new RefreshTokenRecord(
|
||||
AccessToken: result.AccessToken!,
|
||||
RefreshToken: result.RefreshToken ?? _tokens.RefreshToken,
|
||||
AccessTokenExpiresAt: ComputeExpiry(result.AccessTokenExpiration, result.AccessToken),
|
||||
IdToken: result.IdentityToken ?? _tokens.IdToken);
|
||||
|
||||
_store.Save(_tokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upload a user avatar to the Yavsc API. The server expects a
|
||||
/// single multipart file named <c>file</c> and validates the image
|
||||
|
|
@ -394,8 +478,9 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
var response = await attemptUpload().ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
var rejectedAccessToken = response.RequestMessage?.Headers.Authorization?.Parameter;
|
||||
response.Dispose();
|
||||
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
||||
await ForceRefreshAsync(ct, rejectedAccessToken).ConfigureAwait(false);
|
||||
response = await attemptUpload().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
|
@ -423,6 +508,42 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
return "Avatar mis à jour.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download a binary resource (PDF, TeX, …) from the API. Unlike
|
||||
/// <see cref="CallAsync{T}(HttpMethod, string, object?, CancellationToken)"/>,
|
||||
/// which JSON-decodes the response, this returns the raw response
|
||||
/// bytes. Reuses the same Bearer-token attach + 401-refresh-retry-once
|
||||
/// contract as <see cref="SetAvatarAsync"/>.
|
||||
/// </summary>
|
||||
public virtual async Task<byte[]> DownloadAsync(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (_tokens is null)
|
||||
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
|
||||
|
||||
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var sendOnce = async () =>
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, path);
|
||||
return await Http.SendAsync(request, ct).ConfigureAwait(false);
|
||||
};
|
||||
|
||||
var response = await sendOnce().ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
var rejectedAccessToken = response.RequestMessage?.Headers.Authorization?.Parameter;
|
||||
response.Dispose();
|
||||
await ForceRefreshAsync(ct, rejectedAccessToken).ConfigureAwait(false);
|
||||
response = await sendOnce().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
_store.Clear();
|
||||
|
|
@ -503,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);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ using Avalonia.Controls.Templates;
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.ViewModels.Commands;
|
||||
using PostIt.ViewModels.Chat;
|
||||
using PostIt.Views;
|
||||
using PostIt.Views.Blogs;
|
||||
using PostIt.Views.Chat;
|
||||
using PostIt.Views.Commands;
|
||||
|
||||
namespace PostIt;
|
||||
|
|
@ -38,8 +41,9 @@ public class ViewLocator : IDataTemplate
|
|||
var services = app!.ServiceProvider!;
|
||||
return data switch
|
||||
{
|
||||
MainViewModel => services.GetRequiredService<MainPage>(),
|
||||
BlogsViewModel => services.GetRequiredService<BlogsPage>(),
|
||||
Settings => services.GetRequiredService<SettingsPage>(),
|
||||
AboutPageViewModel => services.GetRequiredService<AboutPage>(),
|
||||
HomePageViewModel => services.GetRequiredService<HomePage>(),
|
||||
ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(),
|
||||
CommandFormsPageViewModel => services.GetRequiredService<CommandFormsPage>(),
|
||||
|
|
@ -51,6 +55,16 @@ public class ViewLocator : IDataTemplate
|
|||
PostAclDialogViewModel => services.GetRequiredService<PostAclDialog>(),
|
||||
BillingQueriesPageViewModel => services.GetRequiredService<BillingQueriesPage>(),
|
||||
BillingQueryDetailsPageViewModel => services.GetRequiredService<BillingQueryDetailsPage>(),
|
||||
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
|
||||
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
|
||||
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
|
||||
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
|
||||
MyFilesViewModel => services.GetRequiredService<MyFilesPage>(),
|
||||
UserProfilePageViewModel => services.GetRequiredService<UserProfilePage>(),
|
||||
PerformerConfigurationPageViewModel => services.GetRequiredService<PerformerConfigurationPage>(),
|
||||
AdministrationPageViewModel => services.GetRequiredService<AdministrationPage>(),
|
||||
ResourceUsagePageViewModel => services.GetRequiredService<ResourceUsagePage>(),
|
||||
ChatViewModel => services.GetRequiredService<ChatPage>(),
|
||||
null => new TextBlock { Text = "No view for <null>" },
|
||||
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ using Avalonia;
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Helpers;
|
||||
using PostIt.Models;
|
||||
using PostIt.Services;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Api.Client.Dtos;
|
||||
|
|
@ -12,6 +12,7 @@ using Yavsc.Api.Client.Dtos;
|
|||
using Yavsc.Abstract.BlogSpot;
|
||||
using Yavsc.Abstract.Identity.Security;
|
||||
using System.Net.Http;
|
||||
using PostIt.Models;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
|
|
@ -8,6 +8,7 @@ using Avalonia;
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PostIt.Helpers;
|
||||
using PostIt.Models;
|
||||
using Yavsc.Abstract.Workflow;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
|
|
@ -7,10 +7,12 @@ using System.Threading.Tasks;
|
|||
using Avalonia;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Helpers;
|
||||
using Yavsc;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Abstract.Workflow;
|
||||
using PostIt.Models;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
|
|
@ -43,7 +45,7 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
|
|||
? $"Demandes en cours ({Form.Title})"
|
||||
: $"Commandes {Form.Title}";
|
||||
public string ContextLabel => $"{Performer.UserName} · {Activity.Name}";
|
||||
public bool CanOpenDetails => true;
|
||||
public bool CanOpenDetails => !IsReadOnly;
|
||||
|
||||
public override bool CanNavigateNext
|
||||
{
|
||||
|
|
@ -75,7 +77,7 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
|
|||
|
||||
public Task InitializeAsync() => RefreshAsync();
|
||||
|
||||
private bool CanOpenSelectedQuery() => SelectedQuery is not null;
|
||||
private bool CanOpenSelectedQuery() => CanOpenDetails && SelectedQuery is not null;
|
||||
|
||||
[RelayCommand]
|
||||
public async Task RefreshAsync()
|
||||
|
|
@ -130,13 +132,19 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
|
|||
try
|
||||
{
|
||||
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
|
||||
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
|
||||
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
|
||||
var vm = new BillingQueryDetailsPageViewModel(
|
||||
Activity,
|
||||
Performer,
|
||||
Form,
|
||||
_billingClient,
|
||||
frontClient,
|
||||
fsClient,
|
||||
details,
|
||||
IsReadOnly);
|
||||
IsReadOnly,
|
||||
canAttach: !IsReadOnly);
|
||||
await vm.InitializeAsync().ConfigureAwait(true);
|
||||
await app.PushPageAsync(vm).ConfigureAwait(true);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||
|
|
@ -171,5 +179,9 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
|
|||
}
|
||||
|
||||
private static bool IsOngoingStatus(QueryStatus status)
|
||||
=> status is QueryStatus.Inserted or QueryStatus.Accepted or QueryStatus.InProgress;
|
||||
=> status is QueryStatus.Inserted
|
||||
or QueryStatus.Accepted
|
||||
or QueryStatus.ProAccepted
|
||||
or QueryStatus.ClientAccepted
|
||||
or QueryStatus.InProgress;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
|
|
@ -14,8 +15,18 @@ namespace PostIt.ViewModels;
|
|||
public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionStatusViewModel
|
||||
{
|
||||
private readonly BillingApiClient _billingClient;
|
||||
private readonly FrontOfficeApiClient _frontClient;
|
||||
private readonly UserFilesApiClient _fsClient;
|
||||
private readonly BillingQueryDetailsDto _details;
|
||||
|
||||
/// <summary>
|
||||
/// Vrai lorsque l'utilisateur courant est le client de la demande
|
||||
/// et peut y attacher/détacher des fichiers de son espace perso.
|
||||
/// Le fournisseur consulte et télécharge les pièces mais n'attache
|
||||
/// pas (il joint ses documents au devis, côté <c>EstimateEditionPage</c>).
|
||||
/// </summary>
|
||||
public bool CanAttach { get; }
|
||||
|
||||
public ActivityInfo Activity { get; }
|
||||
public ActivityUserDisplayItem Performer { get; }
|
||||
public CommandFormSummary Form { get; }
|
||||
|
|
@ -51,6 +62,10 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
|||
[ObservableProperty]
|
||||
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
|
||||
|
||||
/// <summary>Pièces jointes rattachées à la demande (par référence).</summary>
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<AttachmentDto> Attachments { get; set; } = new();
|
||||
|
||||
public override bool CanNavigateNext
|
||||
{
|
||||
get => false;
|
||||
|
|
@ -68,19 +83,28 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
|||
ActivityUserDisplayItem performer,
|
||||
CommandFormSummary form,
|
||||
BillingApiClient billingClient,
|
||||
FrontOfficeApiClient frontClient,
|
||||
UserFilesApiClient fsClient,
|
||||
BillingQueryDetailsDto details,
|
||||
bool isReadOnly)
|
||||
bool isReadOnly,
|
||||
bool canAttach)
|
||||
{
|
||||
Activity = activity ?? throw new ArgumentNullException(nameof(activity));
|
||||
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
|
||||
Form = form ?? throw new ArgumentNullException(nameof(form));
|
||||
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
|
||||
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
|
||||
_fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient));
|
||||
_details = details ?? throw new ArgumentNullException(nameof(details));
|
||||
IsReadOnly = isReadOnly;
|
||||
CanAttach = canAttach;
|
||||
|
||||
this.SetInfoStatus("Details de commande charges.");
|
||||
}
|
||||
|
||||
/// <summary>Constructeur pour le designer Avalonia.</summary>
|
||||
public BillingQueryDetailsPageViewModel() : this(null!, null!, null!, null!, null!, null!, null!, false, false) { }
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenEditorAsync()
|
||||
{
|
||||
|
|
@ -131,6 +155,157 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
|||
await app.GoBackAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Charge les pièces jointes de la demande.</summary>
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadQueryAttachmentsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var list = await _frontClient
|
||||
.GetQueryAttachmentsAsync(BillingCode, Id)
|
||||
.ConfigureAwait(true);
|
||||
Attachments.Clear();
|
||||
if (list is not null)
|
||||
foreach (var a in list) Attachments.Add(a);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non bloquant : les pièces jointes sont un complément.
|
||||
this.SetWarningStatus($"Pièces jointes indisponibles : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi
|
||||
/// est rattaché à la demande par référence via
|
||||
/// <c>POST front/query/{id}/attachments</c>. Réservé au client
|
||||
/// (<see cref="CanAttach"/>).
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private async Task AddQueryAttachmentAsync()
|
||||
{
|
||||
if (!CanAttach) return;
|
||||
|
||||
var app = (App?)Application.Current;
|
||||
if (app is null)
|
||||
{
|
||||
throw new InvalidOperationException("Application PostIt indisponible.");
|
||||
}
|
||||
|
||||
var billingCode = BillingCode;
|
||||
var queryId = Id;
|
||||
var picker = new MyFilesViewModel(_fsClient, async fileId =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _frontClient.AttachQueryFileAsync(billingCode, queryId, fileId).ConfigureAwait(true);
|
||||
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
|
||||
this.SetInfoStatus("Pièce jointe ajoutée à la demande.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Échec de l'attachement : {ex.Message}");
|
||||
}
|
||||
});
|
||||
await picker.InitializeAsync().ConfigureAwait(true);
|
||||
await app.PushPageAsync(picker).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DetachQueryFileAsync(AttachmentDto? attachment)
|
||||
{
|
||||
if (attachment is null || !CanAttach) return;
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
await _frontClient.DetachQueryFileAsync(BillingCode, Id, attachment.FileId).ConfigureAwait(true);
|
||||
Attachments.Remove(attachment);
|
||||
this.SetInfoStatus("Pièce jointe détachée de la demande.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Échec du détachement : {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DownloadQueryAttachmentAsync(AttachmentDto? attachment)
|
||||
{
|
||||
if (attachment is null) return;
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true);
|
||||
var name = System.IO.Path.GetFileName(attachment.Path ?? "file");
|
||||
var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true);
|
||||
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download the estimate linked to this command as a LaTeX source
|
||||
/// (<c>GET api/v1/front/query/{Id}/estimate.tex</c>). The estimate is
|
||||
/// resolved server-side from <c>Estimate.CommandId == query Id</c>.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
public async Task DownloadTexAsync()
|
||||
{
|
||||
await DownloadAsync(
|
||||
() => _frontClient.GetEstimateTexAsync(_details.Id),
|
||||
$"devis-{_details.Id}", "tex").ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download the estimate linked to this command as a compiled PDF
|
||||
/// (<c>GET api/v1/front/query/{Id}/estimate.pdf</c>). Requires
|
||||
/// <c>texi2pdf</c> on the server host.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
public async Task DownloadPdfAsync()
|
||||
{
|
||||
await DownloadAsync(
|
||||
() => _frontClient.GetEstimatePdfAsync(_details.Id),
|
||||
$"devis-{_details.Id}", "pdf").ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private async Task DownloadAsync(Func<Task<byte[]>> fetch, string fileName, string ext)
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var bytes = await fetch().ConfigureAwait(true);
|
||||
var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true);
|
||||
this.SetInfoStatus(saved
|
||||
? $"Fichier .{ext} enregistré."
|
||||
: "Téléchargement annulé.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Échec du téléchargement: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string EmptyAsPlaceholder(string? value, string placeholder)
|
||||
=> string.IsNullOrWhiteSpace(value) ? placeholder : value;
|
||||
|
||||
|
|
@ -166,6 +341,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
|||
=> status switch
|
||||
{
|
||||
QueryStatus.Accepted => "#E6F7EC",
|
||||
QueryStatus.ProAccepted => "#E6F7EC",
|
||||
QueryStatus.ClientAccepted => "#E1F5FE",
|
||||
QueryStatus.InProgress => "#FFF4D6",
|
||||
QueryStatus.Rejected => "#FDECEA",
|
||||
QueryStatus.Failed => "#ECEFF1",
|
||||
|
|
@ -177,6 +354,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
|||
=> status switch
|
||||
{
|
||||
QueryStatus.Accepted => "#2E7D32",
|
||||
QueryStatus.ProAccepted => "#2E7D32",
|
||||
QueryStatus.ClientAccepted => "#1565C0",
|
||||
QueryStatus.InProgress => "#B26A00",
|
||||
QueryStatus.Rejected => "#C62828",
|
||||
QueryStatus.Failed => "#607D8B",
|
||||
|
|
@ -188,6 +367,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
|||
=> status switch
|
||||
{
|
||||
QueryStatus.Accepted => "#1B5E20",
|
||||
QueryStatus.ProAccepted => "#1B5E20",
|
||||
QueryStatus.ClientAccepted => "#0D47A1",
|
||||
QueryStatus.InProgress => "#7A4A00",
|
||||
QueryStatus.Rejected => "#8E0000",
|
||||
QueryStatus.Failed => "#37474F",
|
||||
|
|
@ -199,6 +380,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
|||
=> status switch
|
||||
{
|
||||
QueryStatus.Accepted => "OK",
|
||||
QueryStatus.ProAccepted => "P",
|
||||
QueryStatus.ClientAccepted => "C",
|
||||
QueryStatus.InProgress => "~",
|
||||
QueryStatus.Rejected => "!",
|
||||
QueryStatus.Failed => "X",
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue