From 7f4068518d11764871c67081d4fcf3fe9bec2965 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 22 Mar 2026 18:59:05 +0100 Subject: [PATCH] test: add draft workflow coverage and sqlite migration helpers --- JobTrackerApi.Tests/ProductionConfigTests.cs | 27 ++++++++ .../SqliteMigrationHelperTests.cs | 25 +++++++ deploy/MARIADB.md | 19 ++++-- deploy/sqlite_export.py | 39 +++++++++++ deploy/sqlite_preview.py | 19 ++++++ .../src/job-details-generated-drafts.test.tsx | 68 +++++++++++++++++++ 6 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 JobTrackerApi.Tests/ProductionConfigTests.cs create mode 100644 JobTrackerApi.Tests/SqliteMigrationHelperTests.cs create mode 100644 deploy/sqlite_export.py create mode 100644 deploy/sqlite_preview.py create mode 100644 job-tracker-ui/src/job-details-generated-drafts.test.tsx diff --git a/JobTrackerApi.Tests/ProductionConfigTests.cs b/JobTrackerApi.Tests/ProductionConfigTests.cs new file mode 100644 index 0000000..5390847 --- /dev/null +++ b/JobTrackerApi.Tests/ProductionConfigTests.cs @@ -0,0 +1,27 @@ +using JobTrackerApi.Controllers; +using JobTrackerApi.Services; +using Microsoft.Extensions.Configuration; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class ProductionConfigTests +{ + [Fact] + public void Summarizer_service_exposes_section_summarization_api() + { + var method = typeof(ISummarizerService).GetMethod("SummarizeSectionAsync"); + Assert.NotNull(method); + } + + [Fact] + public void Profile_cv_controller_supports_pdf_and_docx_extensions() + { + var field = typeof(ProfileCvController).GetField("AllowedExtensions", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert.NotNull(field); + var values = ((System.Collections.IEnumerable)field!.GetValue(null)!).Cast().ToHashSet(StringComparer.OrdinalIgnoreCase); + Assert.Contains(".pdf", values); + Assert.Contains(".docx", values); + } +} diff --git a/JobTrackerApi.Tests/SqliteMigrationHelperTests.cs b/JobTrackerApi.Tests/SqliteMigrationHelperTests.cs new file mode 100644 index 0000000..dbd967d --- /dev/null +++ b/JobTrackerApi.Tests/SqliteMigrationHelperTests.cs @@ -0,0 +1,25 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class SqliteMigrationHelperTests +{ + [Fact] + public void Export_payload_shape_can_be_serialized() + { + var payload = new + { + users = new[] { new { Id = "u1", Email = "u@example.com" } }, + companies = new[] { new { Id = 1, Name = "Acme" } }, + jobs = new[] { new { Id = 1, JobTitle = "Backend Dev" } } + }; + + var json = JsonSerializer.Serialize(payload); + Assert.Contains("users", json); + Assert.Contains("companies", json); + Assert.Contains("jobs", json); + } +} diff --git a/deploy/MARIADB.md b/deploy/MARIADB.md index cc1ce11..b2d0576 100644 --- a/deploy/MARIADB.md +++ b/deploy/MARIADB.md @@ -37,15 +37,22 @@ That means the MariaDB schema is created automatically as long as: - the backend container can reach `mariadb` ## 4. SQLite to MariaDB migration notes -This repo does **not** yet include an automated data migration tool. -If you already have real data in SQLite, recommended migration path is: +This repo now includes helper tooling to make migration safer: +- `deploy/sqlite_export.py` exports the important SQLite tables into JSON +- `deploy/sqlite_preview.py` lets you inspect the exported payload quickly + +Suggested migration path: 1. Stop writes to the app 2. Back up SQLite DB file -3. Start MariaDB-backed environment on a staging copy -4. Export/import the critical tables with a small migration script or one-time tool -5. Validate users, companies, jobs, correspondence, attachments metadata -6. Switch production `.env` to MariaDB +3. Export SQLite data: + - `python deploy/sqlite_export.py /path/to/jobtracker.db sqlite-export.json` +4. Preview the export: + - `python deploy/sqlite_preview.py sqlite-export.json` +5. Start MariaDB-backed environment on a staging copy +6. Import with a one-time script/manual loader tailored to your production data +7. Validate users, companies, jobs, correspondence, attachments metadata +8. Switch production `.env` to MariaDB ## Tables you would likely want to migrate - `AspNetUsers` diff --git a/deploy/sqlite_export.py b/deploy/sqlite_export.py new file mode 100644 index 0000000..8db2500 --- /dev/null +++ b/deploy/sqlite_export.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +import json +import sqlite3 +import sys +from pathlib import Path + +TABLES = [ + 'AspNetUsers', + 'AspNetRoles', + 'AspNetUserRoles', + 'Companies', + 'JobApplications', + 'Correspondences', + 'Attachments', + 'JobEvents', + 'GmailConnections', + 'UserRuleSettings', +] + + +def export_db(sqlite_path: str, output_path: str) -> None: + conn = sqlite3.connect(sqlite_path) + conn.row_factory = sqlite3.Row + payload = {} + for table in TABLES: + try: + rows = conn.execute(f'SELECT * FROM {table}').fetchall() + payload[table] = [dict(row) for row in rows] + except sqlite3.Error: + payload[table] = [] + Path(output_path).write_text(json.dumps(payload, default=str, indent=2), encoding='utf-8') + conn.close() + + +if __name__ == '__main__': + if len(sys.argv) != 3: + print('Usage: sqlite_export.py ') + raise SystemExit(1) + export_db(sys.argv[1], sys.argv[2]) diff --git a/deploy/sqlite_preview.py b/deploy/sqlite_preview.py new file mode 100644 index 0000000..c21ae89 --- /dev/null +++ b/deploy/sqlite_preview.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +import json +import sys +from pathlib import Path + + +def main(path: str) -> None: + payload = json.loads(Path(path).read_text(encoding='utf-8')) + for table, rows in payload.items(): + print(f'-- {table}: {len(rows)} rows') + for row in rows[:2]: + print(row) + + +if __name__ == '__main__': + if len(sys.argv) != 2: + print('Usage: sqlite_preview.py ') + raise SystemExit(1) + main(sys.argv[1]) diff --git a/job-tracker-ui/src/job-details-generated-drafts.test.tsx b/job-tracker-ui/src/job-details-generated-drafts.test.tsx new file mode 100644 index 0000000..3b796f2 --- /dev/null +++ b/job-tracker-ui/src/job-details-generated-drafts.test.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { ConfirmProvider } from './confirm'; +import { PromptProvider } from './prompt'; +import { ToastProvider } from './toast'; +import JobDetailsDialog from './components/JobDetailsDialog'; +import { api } from './api'; + +jest.mock('./api', () => ({ + api: { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }, +})); + +const mockedApi = api as jest.Mocked; + +function renderDialog() { + return render( + + + + {}} /> + + + , + ); +} + +beforeEach(() => { + mockedApi.get.mockImplementation((url: string) => { + if (url === '/jobapplications/42') { + return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme', recruiterEmail: 'recruiter@acme.test' }, tailoredCvText: '', shortSummary: 'summary' } } as any); + } + if (url === '/auth/me') { + return Promise.resolve({ data: { roles: [], profileCvText: 'Master CV text' } } as any); + } + if (url === '/jobapplications/42/history') { + return Promise.resolve({ data: [] } as any); + } + return Promise.resolve({ data: [] } as any); + }); + mockedApi.post.mockResolvedValue({ data: { tailoredCvText: 'Generated CV', coverLetterDraft: 'Draft letter', applicationAnswerDraft: 'Draft answer', recruiterMessageDraft: 'Recruiter hello', keyPoints: ['Lead with .NET'] } } as any); + mockedApi.put.mockResolvedValue({ data: {} } as any); +}); + +test('generated application package can be edited and saved', async () => { + renderDialog(); + + fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i })); + fireEvent.click(await screen.findByRole('button', { name: /generate application package/i })); + + expect(await screen.findByDisplayValue('Generated CV')).toBeInTheDocument(); + const coverLetter = await screen.findByDisplayValue('Draft letter'); + fireEvent.change(coverLetter, { target: { value: 'Edited cover letter' } }); + + const saveButtons = screen.getAllByRole('button', { name: /^save$/i }); + fireEvent.click(saveButtons[0]); + + await waitFor(() => { + expect(mockedApi.put).toHaveBeenCalled(); + }); +});