feat: add editable application drafts tests and gitea deploy workflow

This commit is contained in:
cesnimda
2026-03-22 18:42:14 +01:00
parent 8041b43f47
commit 1fe3a68901
6 changed files with 239 additions and 3 deletions
+72
View File
@@ -0,0 +1,72 @@
name: CI and Deploy
on:
push:
branches:
- main
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: job-tracker-ui/package-lock.json
- name: Build backend
run: dotnet build JobTrackerApi/JobTrackerApi.csproj --configuration Release
- name: Test backend
run: dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-build
- name: Install frontend deps
working-directory: job-tracker-ui
run: npm ci
- name: Test frontend
working-directory: job-tracker-ui
run: npm test -- --watchAll=false --runInBand App.test.tsx confirm.test.tsx prompt.test.tsx dialog-flow.test.tsx confirm-flow.test.tsx attachments.test.tsx
deploy:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Upload deploy bundle to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
source: "."
target: "/opt/job-tracker/app"
rm: true
- name: Run remote deploy
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd /opt/job-tracker/app
chmod +x deploy/deploy.sh
APP_VERSION=${{ github.run_number }} \
APP_COMMIT_SHA=${{ github.sha }} \
APP_BUILD_STAMP="$(date -u +'%Y-%m-%d %H:%M UTC')" \
./deploy/deploy.sh
@@ -0,0 +1,85 @@
using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class JobApplicationsEndpointBehaviorTests
{
[Fact]
public async Task Save_application_drafts_updates_cover_letter_and_notes()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication { JobTitle = "Backend Dev", CompanyId = company.Id, OwnerUserId = "user-1" };
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var controller = CreateController(db, "user-1");
var result = await controller.SaveApplicationDrafts(job.Id, new JobApplicationsController.SaveApplicationDraftsRequest(" Cover letter body ", " Notes body "), CancellationToken.None);
Assert.IsType<NoContentResult>(result);
var saved = await db.JobApplications.FirstAsync();
Assert.Equal("Cover letter body", saved.CoverLetterText);
Assert.Contains("Notes body", saved.Notes);
}
[Fact]
public async Task Generate_application_package_rejects_missing_profile_cv()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
db.Users.Add(new ApplicationUser { Id = "user-1", UserName = "u", Email = "u@example.com" });
await db.SaveChangesAsync();
var job = new JobApplication { JobTitle = "Backend Dev", CompanyId = company.Id, OwnerUserId = "user-1", Description = "Need .NET and SQL" };
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var controller = CreateController(db, "user-1");
var result = await controller.GenerateApplicationPackage(job.Id, null, CancellationToken.None);
var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result);
Assert.Contains("Profile page", badRequest.Value?.ToString());
}
private static JobApplicationsController CreateController(JobTrackerContext db, string userId)
{
var summarizer = new Mock<ISummarizerService>();
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>())).ReturnsAsync("generated text");
var controller = new JobApplicationsController(db, summarizer.Object, Mock.Of<IAppEmailSender>());
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, userId)
}, "test"))
}
};
return controller;
}
private static JobTrackerContext CreateDb()
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(x => x.UserId).Returns("user-1");
return new JobTrackerContext(options, currentUser.Object);
}
}
@@ -14,6 +14,7 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.2" />
</ItemGroup>
<ItemGroup>
+53
View File
@@ -0,0 +1,53 @@
# Production deployment notes
## Gitea Actions
This repo includes `.gitea/workflows/ci-deploy.yml` for:
- backend build
- backend tests
- frontend tests
- deployment to Ubuntu after successful tests on `main`
### Required secrets in Gitea
- `PROD_HOST`
- `PROD_USER`
- `PROD_SSH_KEY`
## Ubuntu server setup
Recommended app path:
- `/opt/job-tracker/app`
Requirements:
- Docker Engine
- Docker Compose plugin
- reverse proxy in front (Nginx, Caddy, or Traefik)
- `.env` file present on server in `/opt/job-tracker/app/.env`
## Database recommendation
For production, yes — use a real database.
### Recommended direction
Short term:
- SQLite is acceptable for a single-user or very small deployment
- keep backups and volume persistence
Better production choice:
- MariaDB or PostgreSQL
### My recommendation
- **PostgreSQL** if you want the best long-term maintainability and fewer edge cases
- **MariaDB** is also fine if that is what you already know or host elsewhere
If you stay on SQLite:
- okay for small personal use
- not ideal for concurrent writes, larger scale, or operational robustness
## Practical recommendation for this project
If this app is going to be a real production service on Ubuntu:
- move to PostgreSQL first if possible
- MariaDB is still a reasonable option if preferred
## Deployment flow
1. push to `main`
2. Gitea Actions runs tests
3. if green, workflow uploads repo to server
4. `deploy/deploy.sh` runs `docker compose build && docker compose up -d`
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
if [ ! -f .env ]; then
echo "Missing .env in deployment directory"
exit 1
fi
export APP_VERSION="${APP_VERSION:-0.0.0}"
export APP_COMMIT_SHA="${APP_COMMIT_SHA:-unknown}"
export APP_BUILD_STAMP="${APP_BUILD_STAMP:-unknown}"
docker compose pull || true
docker compose build
docker compose up -d --remove-orphans
echo "Deployment complete: ${APP_VERSION} ${APP_COMMIT_SHA}"
@@ -462,16 +462,22 @@ function ListCard({ title, items }: { title: string; items: string[] }) {
}
function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise<void> | void; saving?: boolean }) {
const [value, setValue] = React.useState(content);
React.useEffect(() => {
setValue(content);
}, [content]);
return (
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Typography variant="overline">{title}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(content)}>Copy</Button>
{onSave ? <Button size="small" variant="contained" disabled={saving} onClick={() => onSave(content)}>{saving ? "Saving..." : "Save"}</Button> : null}
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>Copy</Button>
{onSave ? <Button size="small" variant="contained" disabled={saving} onClick={() => onSave(value)}>{saving ? "Saving..." : "Save"}</Button> : null}
</Box>
</Box>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{content}</Typography>
<TextField value={value} onChange={(e) => setValue(e.target.value)} multiline minRows={6} fullWidth />
</Box>
);
}