feature: Barcode/QR Cover Sheet System
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for barcode-assisted batch creation via cover sheet codes.
|
||||
/// </summary>
|
||||
[Collection("Database")]
|
||||
public class CoverSheetBatchTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ApiFixture _fixture;
|
||||
private HttpClient _intakeClient = null!;
|
||||
|
||||
public CoverSheetBatchTests(ApiFixture fixture) => _fixture = fixture;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await DbResetHelper.ResetAsync(db);
|
||||
await DataSeeder.SeedAsync(db);
|
||||
_intakeClient = await AuthHelper.LoginAsync(_fixture, "intake1");
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithCoverSheetCode_CreatesBatchAndRedeemsCoverSheet()
|
||||
{
|
||||
var code = await GenerateCoverSheetCodeAsync("VITALS_SHEET", "BACKFILL");
|
||||
|
||||
var response = await UploadWithCoverSheetAsync(code);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var batchId = body.GetProperty("data").GetProperty("id").GetGuid();
|
||||
body.GetProperty("data").GetProperty("batchType").GetString()
|
||||
.Should().Be("VITALS_SHEET");
|
||||
body.GetProperty("data").GetProperty("track").GetString()
|
||||
.Should().Be("BACKFILL");
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var sheet = await db.CoverSheets.FirstAsync(c => c.Code == code);
|
||||
sheet.IsUsed.Should().BeTrue();
|
||||
sheet.BatchId.Should().Be(batchId);
|
||||
sheet.UsedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithUsedCoverSheetCode_Returns409()
|
||||
{
|
||||
var code = await GenerateCoverSheetCodeAsync("VITALS_SHEET", "BACKFILL");
|
||||
|
||||
var first = await UploadWithCoverSheetAsync(code);
|
||||
first.EnsureSuccessStatusCode();
|
||||
|
||||
var second = await UploadWithCoverSheetAsync(code);
|
||||
second.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||
|
||||
var body = await second.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("error").GetProperty("code").GetString()
|
||||
.Should().Be("COVER_SHEET_ALREADY_USED");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithUnknownCoverSheetCode_Returns404()
|
||||
{
|
||||
var response = await UploadWithCoverSheetAsync("VCR-CS-DEADBEEF");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("error").GetProperty("code").GetString()
|
||||
.Should().Be("COVER_SHEET_NOT_FOUND");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithCoverSheetPreAssigned_AutoAssignsBatch()
|
||||
{
|
||||
Guid entryUserId;
|
||||
using (var scope = _fixture.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
entryUserId = await BatchSeedHelper.UserIdAsync(db, "entry1");
|
||||
}
|
||||
|
||||
var code = await GenerateCoverSheetCodeAsync(
|
||||
"LAB_RESULTS", "BACKFILL", assignToUserId: entryUserId);
|
||||
|
||||
var response = await UploadWithCoverSheetAsync(code);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("data").GetProperty("batchType").GetString()
|
||||
.Should().Be("LAB_RESULTS");
|
||||
body.GetProperty("data").GetProperty("enteredByUserId").GetGuid()
|
||||
.Should().Be(entryUserId);
|
||||
body.GetProperty("data").GetProperty("status").GetString()
|
||||
.Should().Be("IN_ENTRY");
|
||||
}
|
||||
|
||||
private async Task<string> GenerateCoverSheetCodeAsync(
|
||||
string batchType,
|
||||
string track,
|
||||
Guid? assignToUserId = null)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
count = 1,
|
||||
batchType,
|
||||
track,
|
||||
assignToUserId
|
||||
};
|
||||
|
||||
var response = await _intakeClient.PostAsJsonAsync("/api/v1/cover-sheets/generate", payload);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return body.GetProperty("data")[0].GetProperty("code").GetString()!;
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> UploadWithCoverSheetAsync(string coverSheetCode)
|
||||
{
|
||||
var fileContent = new ByteArrayContent(
|
||||
System.Text.Encoding.ASCII.GetBytes(
|
||||
$"%PDF-1.4\n%%EOF\n%test-{Guid.NewGuid()}"));
|
||||
fileContent.Headers.ContentType =
|
||||
new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
|
||||
var formData = new MultipartFormDataContent
|
||||
{
|
||||
{ fileContent, "file", "test.pdf" },
|
||||
{ new StringContent(coverSheetCode), "coverSheetCode" }
|
||||
};
|
||||
|
||||
return await _intakeClient.PostAsync("/api/v1/digitization-batches", formData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for cover sheet PDF generation endpoints and the PDF builder.
|
||||
/// </summary>
|
||||
[Collection("Database")]
|
||||
public class CoverSheetPdfTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ApiFixture _fixture;
|
||||
private HttpClient _intakeClient = null!;
|
||||
|
||||
public CoverSheetPdfTests(ApiFixture fixture) => _fixture = fixture;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await DbResetHelper.ResetAsync(db);
|
||||
await DataSeeder.SeedAsync(db);
|
||||
_intakeClient = await AuthHelper.LoginAsync(_fixture, "intake1");
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public void Generate_ProducesValidPdfWithCoverSheetMetadata()
|
||||
{
|
||||
var sheet = new CoverSheet
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "VCR-CS-A3F7B2D1",
|
||||
BatchType = BatchType.VitalsSheet,
|
||||
Track = BatchTrack.Backfill,
|
||||
CreatedAt = new DateTimeOffset(2026, 6, 27, 12, 0, 0, TimeSpan.Zero),
|
||||
Patient = new Patient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FullName = "Maria Garcia",
|
||||
Mrn = "MRN-12345"
|
||||
},
|
||||
AssignToUser = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FullName = "Entry Clerk One"
|
||||
}
|
||||
};
|
||||
|
||||
var pdf = CoverSheetPdfGenerator.Generate(sheet);
|
||||
|
||||
var text = Encoding.ASCII.GetString(pdf);
|
||||
text.Should().StartWith("%PDF-1.4");
|
||||
text.Should().Contain("VCR-CS-A3F7B2D1");
|
||||
text.Should().Contain("VITALS_SHEET");
|
||||
text.Should().Contain("BACKFILL");
|
||||
text.Should().Contain("Maria Garcia");
|
||||
text.Should().Contain("MRN-12345");
|
||||
text.Should().Contain("Entry Clerk One");
|
||||
text.Should().Contain("2026-06-27");
|
||||
text.Should().Contain("Attach to front of chart section");
|
||||
text.Should().Contain("/Subtype /Image");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateBatch_ProducesMultiPagePdf()
|
||||
{
|
||||
var sheets = new[]
|
||||
{
|
||||
new CoverSheet
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "VCR-CS-11111111",
|
||||
BatchType = BatchType.VitalsSheet,
|
||||
Track = BatchTrack.Backfill,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new CoverSheet
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "VCR-CS-22222222",
|
||||
BatchType = BatchType.LabResults,
|
||||
Track = BatchTrack.Backfill,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
var pdf = CoverSheetPdfGenerator.GenerateBatch(sheets);
|
||||
var text = Encoding.ASCII.GetString(pdf);
|
||||
|
||||
text.Should().Contain("/Count 2");
|
||||
text.Should().Contain("VCR-CS-11111111");
|
||||
text.Should().Contain("VCR-CS-22222222");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePdf_Endpoint_ReturnsPdfForCoverSheetId()
|
||||
{
|
||||
var sheetId = await GenerateCoverSheetIdAsync();
|
||||
|
||||
var response = await _intakeClient.PostAsync($"/api/v1/cover-sheets/{sheetId}/pdf", null);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType!.MediaType.Should().Be("application/pdf");
|
||||
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync();
|
||||
var text = Encoding.ASCII.GetString(bytes);
|
||||
text.Should().StartWith("%PDF-1.4");
|
||||
text.Should().Contain("VCR-CS-");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateBatchPdf_Endpoint_ReturnsMultiPagePdf()
|
||||
{
|
||||
var ids = new List<Guid>
|
||||
{
|
||||
await GenerateCoverSheetIdAsync(),
|
||||
await GenerateCoverSheetIdAsync()
|
||||
};
|
||||
|
||||
var response = await _intakeClient.PostAsJsonAsync(
|
||||
"/api/v1/cover-sheets/batch-pdf",
|
||||
new { coverSheetIds = ids });
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType!.MediaType.Should().Be("application/pdf");
|
||||
|
||||
var text = Encoding.ASCII.GetString(await response.Content.ReadAsByteArrayAsync());
|
||||
text.Should().Contain("/Count 2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePdf_UnknownId_Returns404()
|
||||
{
|
||||
var response = await _intakeClient.PostAsync(
|
||||
$"/api/v1/cover-sheets/{Guid.NewGuid()}/pdf", null);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
private async Task<Guid> GenerateCoverSheetIdAsync()
|
||||
{
|
||||
var response = await _intakeClient.PostAsJsonAsync(
|
||||
"/api/v1/cover-sheets/generate",
|
||||
new { count = 1, batchType = "VITALS_SHEET", track = "BACKFILL" });
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return body.GetProperty("data")[0].GetProperty("id").GetGuid();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/cover-sheets")]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class CoverSheetController : ControllerBase
|
||||
{
|
||||
private readonly ICoverSheetService _coverSheets;
|
||||
|
||||
public CoverSheetController(ICoverSheetService coverSheets)
|
||||
{
|
||||
_coverSheets = coverSheets;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates one or more cover sheets with unique barcode codes.
|
||||
/// Each cover sheet encodes batch type, track, optional patient, and
|
||||
/// optional entry clerk assignment.
|
||||
/// </summary>
|
||||
[HttpPost("generate")]
|
||||
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<CoverSheetResponse>>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Generate([FromBody] GenerateCoverSheetsRequest request)
|
||||
{
|
||||
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
var sheets = await _coverSheets.GenerateAsync(request, actorUserId);
|
||||
var response = sheets.Select(MapToResponse).ToList();
|
||||
return StatusCode(201, ApiResponse<List<CoverSheetResponse>>.Created(response));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a cover sheet by its barcode code. Used during barcode-assisted
|
||||
/// upload to auto-populate batch type, track, patient, and clerk assignment.
|
||||
/// </summary>
|
||||
[HttpGet("lookup/{code}")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CoverSheetResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Lookup(string code)
|
||||
{
|
||||
var sheet = await _coverSheets.LookupByCodeAsync(code);
|
||||
if (sheet is null)
|
||||
return NotFound(ApiResponse<object>.Fail(404, "Cover sheet not found.", "COVER_SHEET_NOT_FOUND"));
|
||||
|
||||
return Ok(ApiResponse<CoverSheetResponse>.Ok(MapToResponse(sheet)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists cover sheets with optional filters.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<CoverSheetResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] bool? isUsed,
|
||||
[FromQuery] Guid? patientId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var sheets = await _coverSheets.ListAsync(isUsed, patientId, page, pageSize);
|
||||
var response = sheets.Select(MapToResponse).ToList();
|
||||
return Ok(ApiResponse<List<CoverSheetResponse>>.Ok(response));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a printable PDF containing cover sheets with QR codes.
|
||||
/// Each page has the cover sheet code as a QR code, plus human-readable
|
||||
/// batch type, patient info, and generation date.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/pdf")]
|
||||
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GeneratePdf(Guid id)
|
||||
{
|
||||
var sheet = await _coverSheets.LookupByIdAsync(id);
|
||||
if (sheet is null)
|
||||
return NotFound(ApiResponse<object>.Fail(404, "Cover sheet not found.", "COVER_SHEET_NOT_FOUND"));
|
||||
|
||||
var pdfBytes = CoverSheetPdfGenerator.Generate(sheet);
|
||||
return File(pdfBytes, "application/pdf", $"coversheet-{sheet.Code}.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a batch PDF containing multiple cover sheets (one per page).
|
||||
/// Accepts a list of cover sheet IDs.
|
||||
/// </summary>
|
||||
[HttpPost("batch-pdf")]
|
||||
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GenerateBatchPdf([FromBody] BatchPdfRequest request)
|
||||
{
|
||||
var sheets = await _coverSheets.GetByIdsAsync(request.CoverSheetIds);
|
||||
var pdfBytes = CoverSheetPdfGenerator.GenerateBatch(sheets);
|
||||
return File(pdfBytes, "application/pdf", $"coversheets-batch-{DateTime.UtcNow:yyyyMMdd}.pdf");
|
||||
}
|
||||
|
||||
private static CoverSheetResponse MapToResponse(CoverSheet sheet) => new(
|
||||
Id: sheet.Id,
|
||||
Code: sheet.Code,
|
||||
BatchType: sheet.BatchType.ToDbString(),
|
||||
Track: sheet.Track.ToDbString(),
|
||||
PatientId: sheet.PatientId,
|
||||
PatientName: sheet.Patient?.FullName,
|
||||
PatientMrn: sheet.Patient?.Mrn,
|
||||
AssignToUserId: sheet.AssignToUserId,
|
||||
AssignToUserName: sheet.AssignToUser?.FullName,
|
||||
IsUsed: sheet.IsUsed,
|
||||
BatchId: sheet.BatchId,
|
||||
CreatedAt: sheet.CreatedAt,
|
||||
UsedAt: sheet.UsedAt
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public class DigitizationBatchesController : ControllerBase
|
||||
private readonly IDocumentStorageService _storage;
|
||||
private readonly IPromotionService _promotion;
|
||||
private readonly IBatchEventService _batchEventService;
|
||||
private readonly ICoverSheetService _coverSheets;
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
private static readonly HashSet<string> _allowedMimeTypes = new()
|
||||
@@ -29,13 +30,15 @@ public class DigitizationBatchesController : ControllerBase
|
||||
IDocumentStorageService storage,
|
||||
IPromotionService promotion,
|
||||
IBatchEventService batchEventService,
|
||||
AppDbContext db)
|
||||
AppDbContext db,
|
||||
ICoverSheetService coverSheets)
|
||||
{
|
||||
_batches = batches;
|
||||
_storage = storage;
|
||||
_promotion = promotion;
|
||||
_batchEventService = batchEventService;
|
||||
_db = db;
|
||||
_coverSheets = coverSheets;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -60,10 +63,40 @@ public class DigitizationBatchesController : ControllerBase
|
||||
return BadRequest(ApiResponse<object>.Fail(400,
|
||||
"Accepted formats: PDF, JPEG, PNG.", "INVALID_MIME_TYPE"));
|
||||
|
||||
var parsedBatchType = BatchTypeExtensions.FromDbString(form.BatchType.ToUpperInvariant());
|
||||
var parsedTrack = string.IsNullOrEmpty(form.Track)
|
||||
? BatchTrack.Backfill
|
||||
: BatchTrackExtensions.FromDbString(form.Track.ToUpperInvariant());
|
||||
CoverSheet? coverSheet = null;
|
||||
BatchType parsedBatchType;
|
||||
BatchTrack parsedTrack;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(form.CoverSheetCode))
|
||||
{
|
||||
coverSheet = await _coverSheets.LookupByCodeAsync(form.CoverSheetCode);
|
||||
if (coverSheet is null)
|
||||
return NotFound(ApiResponse<object>.Fail(404,
|
||||
"Cover sheet not found.", "COVER_SHEET_NOT_FOUND"));
|
||||
|
||||
if (coverSheet.IsUsed)
|
||||
return Conflict(ApiResponse<object>.Fail(409,
|
||||
$"Cover sheet {coverSheet.Code} has already been used.",
|
||||
"COVER_SHEET_ALREADY_USED"));
|
||||
|
||||
parsedBatchType = coverSheet.BatchType;
|
||||
parsedTrack = coverSheet.Track;
|
||||
if (coverSheet.PatientId.HasValue)
|
||||
form.PatientId ??= coverSheet.PatientId;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(form.BatchType))
|
||||
return BadRequest(ApiResponse<object>.Fail(400,
|
||||
"BatchType is required when no cover sheet code is provided.",
|
||||
"MISSING_BATCH_TYPE"));
|
||||
|
||||
parsedBatchType = BatchTypeExtensions.FromDbString(form.BatchType.ToUpperInvariant());
|
||||
parsedTrack = string.IsNullOrEmpty(form.Track)
|
||||
? BatchTrack.Backfill
|
||||
: BatchTrackExtensions.FromDbString(form.Track.ToUpperInvariant());
|
||||
}
|
||||
|
||||
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
using var stream = form.File.OpenReadStream();
|
||||
@@ -71,8 +104,17 @@ public class DigitizationBatchesController : ControllerBase
|
||||
stream, form.File.ContentType, parsedBatchType, parsedTrack,
|
||||
form.PatientId, form.SupersedesBatchId, actorUserId);
|
||||
|
||||
var batch = result.Batch;
|
||||
if (coverSheet is not null)
|
||||
{
|
||||
await _coverSheets.RedeemAsync(coverSheet.Id, batch.Id);
|
||||
|
||||
if (coverSheet.AssignToUserId.HasValue)
|
||||
batch = await _batches.AssignAsync(batch.Id, coverSheet.AssignToUserId.Value, actorUserId);
|
||||
}
|
||||
|
||||
return StatusCode(201, ApiResponse<BatchDetailResponse>.Created(
|
||||
BatchDetailResponse.FromEntity(result.Batch, supersession: result.Supersession)));
|
||||
BatchDetailResponse.FromEntity(batch, supersession: result.Supersession)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -23,6 +23,7 @@ public class AppDbContext : DbContext
|
||||
public DbSet<LiveEncounter> LiveEncounters => Set<LiveEncounter>();
|
||||
public DbSet<LiveObservation> LiveObservations => Set<LiveObservation>();
|
||||
public DbSet<PromotionAttempt> PromotionAttempts => Set<PromotionAttempt>();
|
||||
public DbSet<CoverSheet> CoverSheets => Set<CoverSheet>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class CoverSheetConfiguration : IEntityTypeConfiguration<CoverSheet>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<CoverSheet> builder)
|
||||
{
|
||||
builder.ToTable("cover_sheets");
|
||||
builder.HasKey(c => c.Id);
|
||||
builder.Property(c => c.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(c => c.Code).HasColumnName("code").HasMaxLength(20).IsRequired();
|
||||
builder.Property(c => c.PatientId).HasColumnName("patient_id");
|
||||
builder.Property(c => c.BatchType).HasColumnName("batch_type")
|
||||
.HasConversion(v => v.ToDbString(), v => BatchTypeExtensions.FromDbString(v))
|
||||
.HasMaxLength(30).IsRequired();
|
||||
builder.Property(c => c.Track).HasColumnName("track")
|
||||
.HasConversion(v => v.ToDbString(), v => BatchTrackExtensions.FromDbString(v))
|
||||
.HasMaxLength(20).IsRequired();
|
||||
builder.Property(c => c.AssignToUserId).HasColumnName("assign_to_user_id");
|
||||
builder.Property(c => c.GeneratedByUserId).HasColumnName("generated_by_user_id").IsRequired();
|
||||
builder.Property(c => c.IsUsed).HasColumnName("is_used").HasDefaultValue(false);
|
||||
builder.Property(c => c.BatchId).HasColumnName("batch_id");
|
||||
builder.Property(c => c.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
builder.Property(c => c.UsedAt).HasColumnName("used_at");
|
||||
|
||||
builder.HasIndex(c => c.Code).IsUnique().HasDatabaseName("ix_cover_sheets_code");
|
||||
builder.HasIndex(c => new { c.IsUsed, c.CreatedAt })
|
||||
.HasFilter("is_used = false")
|
||||
.HasDatabaseName("ix_cover_sheets_unused");
|
||||
|
||||
builder.HasOne(c => c.Patient)
|
||||
.WithMany().HasForeignKey(c => c.PatientId).OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne(c => c.AssignToUser)
|
||||
.WithMany().HasForeignKey(c => c.AssignToUserId).OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne(c => c.GeneratedByUser)
|
||||
.WithMany().HasForeignKey(c => c.GeneratedByUserId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(c => c.Batch)
|
||||
.WithMany().HasForeignKey(c => c.BatchId).OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
+1572
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareRecordsAPI.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCoverSheets : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "cover_sheets",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
code = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
patient_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
batch_type = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
track = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
assign_to_user_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
generated_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
is_used = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
batch_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||
used_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_cover_sheets", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_cover_sheets_digitization_batches_batch_id",
|
||||
column: x => x.batch_id,
|
||||
principalTable: "digitization_batches",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_cover_sheets_patients_patient_id",
|
||||
column: x => x.patient_id,
|
||||
principalSchema: "clinical",
|
||||
principalTable: "patients",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_cover_sheets_users_assign_to_user_id",
|
||||
column: x => x.assign_to_user_id,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_cover_sheets_users_generated_by_user_id",
|
||||
column: x => x.generated_by_user_id,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_cover_sheets_assign_to_user_id",
|
||||
table: "cover_sheets",
|
||||
column: "assign_to_user_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_cover_sheets_batch_id",
|
||||
table: "cover_sheets",
|
||||
column: "batch_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_cover_sheets_code",
|
||||
table: "cover_sheets",
|
||||
column: "code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_cover_sheets_generated_by_user_id",
|
||||
table: "cover_sheets",
|
||||
column: "generated_by_user_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_cover_sheets_patient_id",
|
||||
table: "cover_sheets",
|
||||
column: "patient_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_cover_sheets_unused",
|
||||
table: "cover_sheets",
|
||||
columns: new[] { "is_used", "created_at" },
|
||||
filter: "is_used = false");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "cover_sheets");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,85 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CoverSheet", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<Guid?>("AssignToUserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("assign_to_user_id");
|
||||
|
||||
b.Property<Guid?>("BatchId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("batch_id");
|
||||
|
||||
b.Property<string>("BatchType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)")
|
||||
.HasColumnName("batch_type");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("code");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<Guid>("GeneratedByUserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("generated_by_user_id");
|
||||
|
||||
b.Property<bool>("IsUsed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_used");
|
||||
|
||||
b.Property<Guid?>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<string>("Track")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("track");
|
||||
|
||||
b.Property<DateTimeOffset?>("UsedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("used_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssignToUserId");
|
||||
|
||||
b.HasIndex("BatchId");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_cover_sheets_code");
|
||||
|
||||
b.HasIndex("GeneratedByUserId");
|
||||
|
||||
b.HasIndex("PatientId");
|
||||
|
||||
b.HasIndex("IsUsed", "CreatedAt")
|
||||
.HasDatabaseName("ix_cover_sheets_unused")
|
||||
.HasFilter("is_used = false");
|
||||
|
||||
b.ToTable("cover_sheets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DigitizationBatch", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1269,6 +1348,38 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CoverSheet", b =>
|
||||
{
|
||||
b.HasOne("User", "AssignToUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("AssignToUserId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("DigitizationBatch", "Batch")
|
||||
.WithMany()
|
||||
.HasForeignKey("BatchId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("User", "GeneratedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("GeneratedByUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Patient", "Patient")
|
||||
.WithMany()
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("AssignToUser");
|
||||
|
||||
b.Navigation("Batch");
|
||||
|
||||
b.Navigation("GeneratedByUser");
|
||||
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DigitizationBatch", b =>
|
||||
{
|
||||
b.HasOne("User", "ApprovedByUser")
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
public class CoverSheet
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Code { get; set; } = null!;
|
||||
public Guid? PatientId { get; set; }
|
||||
public BatchType BatchType { get; set; }
|
||||
public BatchTrack Track { get; set; }
|
||||
public Guid? AssignToUserId { get; set; }
|
||||
public Guid GeneratedByUserId { get; set; }
|
||||
public bool IsUsed { get; set; }
|
||||
public Guid? BatchId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? UsedAt { get; set; }
|
||||
|
||||
public Patient? Patient { get; set; }
|
||||
public User? AssignToUser { get; set; }
|
||||
public User GeneratedByUser { get; set; } = null!;
|
||||
public DigitizationBatch? Batch { get; set; }
|
||||
}
|
||||
@@ -5,8 +5,10 @@ public class CreateBatchForm
|
||||
[Required]
|
||||
public IFormFile File { get; set; } = null!;
|
||||
|
||||
[Required]
|
||||
public string BatchType { get; set; } = null!;
|
||||
/// <summary>
|
||||
/// Required unless <see cref="CoverSheetCode"/> is provided; cover sheet values override when both are sent.
|
||||
/// </summary>
|
||||
public string? BatchType { get; set; }
|
||||
|
||||
public string? Track { get; set; }
|
||||
|
||||
@@ -15,5 +17,8 @@ public class CreateBatchForm
|
||||
public Guid? SupersedesBatchId { get; set; }
|
||||
|
||||
public CreateBatchRequest ToMetadata() =>
|
||||
new(BatchType, Track, PatientId, SupersedesBatchId);
|
||||
new(BatchType ?? throw new InvalidOperationException("BatchType is required."),
|
||||
Track, PatientId, SupersedesBatchId);
|
||||
|
||||
public string? CoverSheetCode { get; set; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
public record BatchPdfRequest(List<Guid> CoverSheetIds);
|
||||
@@ -0,0 +1,15 @@
|
||||
public record CoverSheetResponse(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string BatchType,
|
||||
string Track,
|
||||
Guid? PatientId,
|
||||
string? PatientName,
|
||||
string? PatientMrn,
|
||||
Guid? AssignToUserId,
|
||||
string? AssignToUserName,
|
||||
bool IsUsed,
|
||||
Guid? BatchId,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? UsedAt
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
public record GenerateCoverSheetsRequest(
|
||||
int Count,
|
||||
string BatchType,
|
||||
string Track,
|
||||
Guid? PatientId,
|
||||
Guid? AssignToUserId
|
||||
);
|
||||
@@ -121,6 +121,7 @@ try
|
||||
builder.Services.AddScoped<IAttestationService, AttestationService>();
|
||||
builder.Services.AddScoped<ILiveCaptureService, LiveCaptureService>();
|
||||
builder.Services.AddScoped<IBatchEventService, BatchEventService>();
|
||||
builder.Services.AddScoped<ICoverSheetService, CoverSheetService>();
|
||||
|
||||
builder.Services.AddHostedService<MetricsCollectorService>();
|
||||
builder.Services.AddHostedService<PromotionRetryService>();
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using QRCoder;
|
||||
|
||||
/// <summary>
|
||||
/// Builds printable cover sheet PDFs using raw PDF 1.4 objects and QRCoder for QR images.
|
||||
/// Each page includes a header, QR code, human-readable metadata, and a footer instruction line.
|
||||
/// </summary>
|
||||
public static class CoverSheetPdfGenerator
|
||||
{
|
||||
private const float PageWidth = 612f;
|
||||
private const float PageHeight = 792f;
|
||||
private const float QrDisplaySize = 200f;
|
||||
private const float LeftMargin = 72f;
|
||||
|
||||
public static byte[] Generate(CoverSheet sheet) =>
|
||||
GenerateBatch(new[] { sheet });
|
||||
|
||||
public static byte[] GenerateBatch(IReadOnlyList<CoverSheet> sheets)
|
||||
{
|
||||
if (sheets.Count == 0)
|
||||
throw new ArgumentException("At least one cover sheet is required.", nameof(sheets));
|
||||
|
||||
var pages = sheets.Select(BuildPage).ToList();
|
||||
return BuildPdf(pages);
|
||||
}
|
||||
|
||||
private sealed record PageData(string ContentStream, byte[] ImageRgb, int ImageWidth, int ImageHeight);
|
||||
|
||||
private static PageData BuildPage(CoverSheet sheet)
|
||||
{
|
||||
var (rgb, width, height) = GenerateQrRgb(sheet.Code);
|
||||
var content = BuildContentStream(sheet);
|
||||
return new PageData(content, rgb, width, height);
|
||||
}
|
||||
|
||||
private static (byte[] Rgb, int Width, int Height) GenerateQrRgb(string code)
|
||||
{
|
||||
using var generator = new QRCodeGenerator();
|
||||
using var data = generator.CreateQrCode(code, QRCodeGenerator.ECCLevel.M);
|
||||
|
||||
var modules = data.ModuleMatrix.Count;
|
||||
const int scale = 8;
|
||||
const int quiet = 2;
|
||||
var size = (modules + quiet * 2) * scale;
|
||||
var rgb = new byte[size * size * 3];
|
||||
|
||||
for (var y = 0; y < size; y++)
|
||||
{
|
||||
for (var x = 0; x < size; x++)
|
||||
{
|
||||
var mx = x / scale - quiet;
|
||||
var my = y / scale - quiet;
|
||||
var dark = mx >= 0 && my >= 0 && mx < modules && my < modules && data.ModuleMatrix[my][mx];
|
||||
var color = dark ? (byte)0 : (byte)255;
|
||||
var i = (y * size + x) * 3;
|
||||
rgb[i] = color;
|
||||
rgb[i + 1] = color;
|
||||
rgb[i + 2] = color;
|
||||
}
|
||||
}
|
||||
|
||||
return (rgb, size, size);
|
||||
}
|
||||
|
||||
private static string BuildContentStream(CoverSheet sheet)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var qrX = (PageWidth - QrDisplaySize) / 2f;
|
||||
const float qrY = 470f;
|
||||
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"q {QrDisplaySize} 0 0 {QrDisplaySize} {qrX} {qrY} cm /Im1 Do Q");
|
||||
|
||||
WriteTextLine(sb, 18f, LeftMargin, 740f, "VigilCare Records - Cover Sheet");
|
||||
|
||||
var y = 430f;
|
||||
y = WriteTextLine(sb, 12f, LeftMargin, y, $"Code: {sheet.Code}");
|
||||
y = WriteTextLine(sb, 12f, LeftMargin, y, $"Batch Type: {sheet.BatchType.ToDbString()}");
|
||||
y = WriteTextLine(sb, 12f, LeftMargin, y, $"Track: {sheet.Track.ToDbString()}");
|
||||
|
||||
if (sheet.Patient is not null)
|
||||
{
|
||||
y = WriteTextLine(sb, 12f, LeftMargin, y,
|
||||
$"Patient: {sheet.Patient.FullName} (MRN {sheet.Patient.Mrn})");
|
||||
}
|
||||
|
||||
if (sheet.AssignToUser is not null)
|
||||
{
|
||||
y = WriteTextLine(sb, 12f, LeftMargin, y,
|
||||
$"Assigned To: {sheet.AssignToUser.FullName}");
|
||||
}
|
||||
|
||||
var generated = sheet.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
WriteTextLine(sb, 12f, LeftMargin, y, $"Generated: {generated}");
|
||||
|
||||
WriteTextLine(sb, 10f, LeftMargin, 48f,
|
||||
"Attach to front of chart section. Scanner reads QR code automatically.");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static float WriteTextLine(StringBuilder sb, float fontSize, float x, float y, string text)
|
||||
{
|
||||
sb.AppendLine("BT");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"/F1 {fontSize} Tf");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"{x} {y} Td");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"({EscapePdfString(text)}) Tj");
|
||||
sb.AppendLine("ET");
|
||||
return y - fontSize - 8f;
|
||||
}
|
||||
|
||||
private static byte[] BuildPdf(IReadOnlyList<PageData> pages)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
ms.Write("%PDF-1.4\n"u8);
|
||||
|
||||
var offsets = new List<long>();
|
||||
const int catalogId = 1;
|
||||
const int pagesId = 2;
|
||||
const int fontId = 3;
|
||||
var pageIds = Enumerable.Range(0, pages.Count).Select(i => 4 + i * 3).ToArray();
|
||||
var contentIds = pageIds.Select(id => id + 1).ToArray();
|
||||
var imageIds = pageIds.Select(id => id + 2).ToArray();
|
||||
|
||||
offsets.Add(ms.Position);
|
||||
WriteAscii(ms, $"{catalogId} 0 obj\n<< /Type /Catalog /Pages {pagesId} 0 R >>\nendobj\n");
|
||||
|
||||
offsets.Add(ms.Position);
|
||||
var kids = string.Join(" ", pageIds.Select(id => $"{id} 0 R"));
|
||||
WriteAscii(ms, $"{pagesId} 0 obj\n<< /Type /Pages /Kids [{kids}] /Count {pages.Count} >>\nendobj\n");
|
||||
|
||||
offsets.Add(ms.Position);
|
||||
WriteAscii(ms, $"{fontId} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n");
|
||||
|
||||
for (var i = 0; i < pages.Count; i++)
|
||||
{
|
||||
var page = pages[i];
|
||||
var pageId = pageIds[i];
|
||||
var contentId = contentIds[i];
|
||||
var imageId = imageIds[i];
|
||||
var contentBytes = Encoding.ASCII.GetBytes(page.ContentStream);
|
||||
var imageBytes = FlateEncode(page.ImageRgb);
|
||||
|
||||
offsets.Add(ms.Position);
|
||||
WriteAscii(ms,
|
||||
$"{pageId} 0 obj\n" +
|
||||
$"<< /Type /Page /Parent {pagesId} 0 R " +
|
||||
$"/MediaBox [0 0 {PageWidth} {PageHeight}] " +
|
||||
$"/Contents {contentId} 0 R " +
|
||||
$"/Resources << /Font << /F1 {fontId} 0 R >> /XObject << /Im1 {imageId} 0 R >> >> >>\n" +
|
||||
"endobj\n");
|
||||
|
||||
offsets.Add(ms.Position);
|
||||
WriteAscii(ms,
|
||||
$"{contentId} 0 obj\n<< /Length {contentBytes.Length} >>\nstream\n");
|
||||
ms.Write(contentBytes);
|
||||
WriteAscii(ms, "\nendstream\nendobj\n");
|
||||
|
||||
offsets.Add(ms.Position);
|
||||
WriteAscii(ms,
|
||||
$"{imageId} 0 obj\n" +
|
||||
$"<< /Type /XObject /Subtype /Image " +
|
||||
$"/Width {page.ImageWidth} /Height {page.ImageHeight} " +
|
||||
"/ColorSpace /DeviceRGB /BitsPerComponent 8 " +
|
||||
$"/Filter /FlateDecode /Length {imageBytes.Length} >>\nstream\n");
|
||||
ms.Write(imageBytes);
|
||||
WriteAscii(ms, "\nendstream\nendobj\n");
|
||||
}
|
||||
|
||||
var xrefOffset = ms.Position;
|
||||
WriteAscii(ms, "xref\n");
|
||||
WriteAscii(ms, $"0 {offsets.Count + 1}\n");
|
||||
WriteAscii(ms, "0000000000 65535 f \n");
|
||||
foreach (var offset in offsets)
|
||||
WriteAscii(ms, $"{offset:D10} 00000 n \n");
|
||||
|
||||
WriteAscii(ms,
|
||||
"trailer\n" +
|
||||
$"<< /Size {offsets.Count + 1} /Root {catalogId} 0 R >>\n" +
|
||||
"startxref\n" +
|
||||
$"{xrefOffset}\n" +
|
||||
"%%EOF\n");
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteAscii(Stream stream, string text) =>
|
||||
stream.Write(Encoding.ASCII.GetBytes(text));
|
||||
|
||||
private static byte[] FlateEncode(byte[] data)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
using (var deflate = new ZLibStream(output, CompressionLevel.Optimal, leaveOpen: true))
|
||||
deflate.Write(data, 0, data.Length);
|
||||
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static string EscapePdfString(string value) =>
|
||||
value.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace("(", "\\(", StringComparison.Ordinal)
|
||||
.Replace(")", "\\)", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class CoverSheetService : ICoverSheetService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ILogger<CoverSheetService> _logger;
|
||||
|
||||
public CoverSheetService(AppDbContext db, ILogger<CoverSheetService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<CoverSheet>> GenerateAsync(
|
||||
GenerateCoverSheetsRequest request, Guid actorUserId)
|
||||
{
|
||||
var count = Math.Clamp(request.Count, 1, 100);
|
||||
var batchType = BatchTypeExtensions.FromDbString(request.BatchType.ToUpperInvariant());
|
||||
var track = string.IsNullOrEmpty(request.Track)
|
||||
? BatchTrack.Backfill
|
||||
: BatchTrackExtensions.FromDbString(request.Track.ToUpperInvariant());
|
||||
|
||||
if (request.PatientId.HasValue)
|
||||
{
|
||||
var patientExists = await _db.Patients.AnyAsync(p => p.Id == request.PatientId.Value);
|
||||
if (!patientExists)
|
||||
throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
|
||||
}
|
||||
|
||||
if (request.AssignToUserId.HasValue)
|
||||
{
|
||||
var user = await _db.Users.FindAsync(request.AssignToUserId.Value);
|
||||
if (user is null || !user.IsActive)
|
||||
throw new NotFoundException("User not found or inactive.", "USER_NOT_FOUND");
|
||||
}
|
||||
|
||||
var sheets = new List<CoverSheet>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var code = $"VCR-CS-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
|
||||
sheets.Add(new CoverSheet
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = code,
|
||||
BatchType = batchType,
|
||||
Track = track,
|
||||
PatientId = request.PatientId,
|
||||
AssignToUserId = request.AssignToUserId,
|
||||
GeneratedByUserId = actorUserId,
|
||||
IsUsed = false,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
_db.CoverSheets.AddRange(sheets);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Generated {Count} cover sheets for batch type {BatchType}, track {Track}",
|
||||
count, batchType.ToDbString(), track.ToDbString());
|
||||
|
||||
return sheets;
|
||||
}
|
||||
|
||||
public async Task<CoverSheet?> LookupByCodeAsync(string code)
|
||||
{
|
||||
return await _db.CoverSheets
|
||||
.Include(c => c.Patient)
|
||||
.Include(c => c.AssignToUser)
|
||||
.FirstOrDefaultAsync(c => c.Code == code.ToUpperInvariant().Trim());
|
||||
}
|
||||
|
||||
public async Task<CoverSheet?> LookupByIdAsync(Guid id)
|
||||
{
|
||||
return await _db.CoverSheets
|
||||
.Include(c => c.Patient)
|
||||
.Include(c => c.AssignToUser)
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
}
|
||||
|
||||
public async Task<List<CoverSheet>> GetByIdsAsync(IReadOnlyList<Guid> ids)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
return new List<CoverSheet>();
|
||||
|
||||
var idSet = ids.Distinct().ToList();
|
||||
var sheets = await _db.CoverSheets
|
||||
.AsNoTracking()
|
||||
.Include(c => c.Patient)
|
||||
.Include(c => c.AssignToUser)
|
||||
.Where(c => idSet.Contains(c.Id))
|
||||
.ToListAsync();
|
||||
|
||||
var byId = sheets.ToDictionary(c => c.Id);
|
||||
return idSet
|
||||
.Where(byId.ContainsKey)
|
||||
.Select(id => byId[id])
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task RedeemAsync(Guid coverSheetId, Guid batchId)
|
||||
{
|
||||
var sheet = await _db.CoverSheets.FindAsync(coverSheetId);
|
||||
if (sheet is null)
|
||||
throw new NotFoundException("Cover sheet not found.", "COVER_SHEET_NOT_FOUND");
|
||||
|
||||
if (sheet.IsUsed)
|
||||
throw new ConflictException(
|
||||
$"Cover sheet {sheet.Code} has already been used for batch {sheet.BatchId}.",
|
||||
"COVER_SHEET_ALREADY_USED");
|
||||
|
||||
sheet.IsUsed = true;
|
||||
sheet.BatchId = batchId;
|
||||
sheet.UsedAt = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<List<CoverSheet>> ListAsync(
|
||||
bool? isUsed, Guid? patientId, int page, int pageSize)
|
||||
{
|
||||
var query = _db.CoverSheets
|
||||
.AsNoTracking()
|
||||
.Include(c => c.Patient)
|
||||
.Include(c => c.AssignToUser)
|
||||
.AsQueryable();
|
||||
|
||||
if (isUsed.HasValue)
|
||||
query = query.Where(c => c.IsUsed == isUsed.Value);
|
||||
if (patientId.HasValue)
|
||||
query = query.Where(c => c.PatientId == patientId.Value);
|
||||
|
||||
return await query
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
public interface ICoverSheetService
|
||||
{
|
||||
Task<List<CoverSheet>> GenerateAsync(GenerateCoverSheetsRequest request, Guid actorUserId);
|
||||
Task<CoverSheet?> LookupByCodeAsync(string code);
|
||||
Task<CoverSheet?> LookupByIdAsync(Guid id);
|
||||
Task<List<CoverSheet>> GetByIdsAsync(IReadOnlyList<Guid> ids);
|
||||
Task RedeemAsync(Guid coverSheetId, Guid batchId);
|
||||
Task<List<CoverSheet>> ListAsync(bool? isUsed, Guid? patientId, int page, int pageSize);
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
|
||||
<PackageReference Include="prometheus-net" Version="8.2.1" />
|
||||
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
<PackageReference Include="QRCoder" Version="1.6.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />
|
||||
|
||||
@@ -667,7 +667,7 @@ Clinical data entry is high-stakes work. Entry clerks need immediate confirmatio
|
||||
|
||||
---
|
||||
|
||||
## P4 — No frontend tests
|
||||
## ~~P4 — No frontend tests~~ DONE
|
||||
|
||||
### Problem
|
||||
|
||||
@@ -816,7 +816,7 @@ A batch stuck in `APPROVED` with exhausted retries is invisible in Prometheus da
|
||||
| 19 | EntryForm missing allergy/med fields | P4 | E | Open |
|
||||
| 20 | No corrections/supersession UI | P4 | E | Open |
|
||||
| 21 | No toast/notification system | P4 | E | Open |
|
||||
| 22 | No frontend tests | P4 | E | Open |
|
||||
| 22 | No frontend tests | P4 | E | Done |
|
||||
| 23 | No field-level draft audit | P5 | F | Open |
|
||||
| 24 | Integration test gaps | P5 | F | Done |
|
||||
| 25 | MetricsCollector missing retry gauges | P5 | F | Done |
|
||||
|
||||
+751
@@ -0,0 +1,751 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs Phase 10 verification checks from docs/plans/phase-10-plan.md.
|
||||
#
|
||||
# Covers cover sheet generation, lookup, list filters, PDF endpoints,
|
||||
# barcode-assisted batch upload, redeem/auto-assign, and reuse prevention.
|
||||
#
|
||||
# Prerequisites:
|
||||
# docker compose up -d (PostgreSQL, Redis, MinIO)
|
||||
# dotnet ef database update --project VigilCareRecordsAPI
|
||||
# dotnet run --project VigilCareRecordsAPI
|
||||
# Phase 1–9 seed data (intake1, entry1, admin1)
|
||||
#
|
||||
# Environment overrides:
|
||||
# VIGILCARE_API_URL default: http://localhost:5217
|
||||
# VIGILCARE_COMPOSE_FILE default: <repo>/docker-compose.yml
|
||||
# VIGILCARE_PG_HOST default: localhost
|
||||
# VIGILCARE_PG_PORT default: 5437
|
||||
# VIGILCARE_PG_DB default: vigilcare_records
|
||||
# VIGILCARE_PG_USER default: postgres
|
||||
# VIGILCARE_PG_PASSWORD default: password
|
||||
# VIGILCARE_SKIP_DB_CHECKS set to 1 to skip PostgreSQL assertions
|
||||
# VIGILCARE_SKIP_TEST_CHECKS set to 1 to skip dotnet integration tests
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x scripts/run-vigilcare-records-phase-10-verification.sh
|
||||
# ./scripts/run-vigilcare-records-phase-10-verification.sh
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
FIXTURE_PDF="$SCRIPT_DIR/fixtures/test-scan.pdf"
|
||||
|
||||
API_URL="${VIGILCARE_API_URL:-http://localhost:5217}"
|
||||
COMPOSE_FILE="${VIGILCARE_COMPOSE_FILE:-$REPO_ROOT/docker-compose.yml}"
|
||||
COMPOSE=(docker compose -f "$COMPOSE_FILE")
|
||||
PG_HOST="${VIGILCARE_PG_HOST:-localhost}"
|
||||
PG_PORT="${VIGILCARE_PG_PORT:-5437}"
|
||||
PG_DB="${VIGILCARE_PG_DB:-vigilcare_records}"
|
||||
PG_USER="${VIGILCARE_PG_USER:-postgres}"
|
||||
PG_PASSWORD="${VIGILCARE_PG_PASSWORD:-password}"
|
||||
SKIP_DB_CHECKS="${VIGILCARE_SKIP_DB_CHECKS:-0}"
|
||||
SKIP_TEST_CHECKS="${VIGILCARE_SKIP_TEST_CHECKS:-0}"
|
||||
|
||||
# Resolved at runtime from the live API (seed IDs vary per environment)
|
||||
ENTRY_CLERK1_ID=""
|
||||
PATIENT1_ID=""
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
FAILED_TESTS=()
|
||||
|
||||
# Shared state populated during the run
|
||||
INTAKE_TOKEN=""
|
||||
ADMIN_TOKEN=""
|
||||
ENTRY_TOKEN=""
|
||||
UPLOAD_CODE=""
|
||||
UPLOAD_COVER_SHEET_ID=""
|
||||
UPLOAD_BATCH_ID=""
|
||||
ASSIGN_CODE=""
|
||||
PDF_SHEET_IDS=()
|
||||
|
||||
log() {
|
||||
printf '%s\n' "$*"
|
||||
}
|
||||
|
||||
section() {
|
||||
log ""
|
||||
log "== $1 =="
|
||||
}
|
||||
|
||||
pass() {
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
log " PASS: $1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
FAILED_TESTS+=("$1")
|
||||
log " FAIL: $1"
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
local cmd="$1"
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
log "ERROR: required command not found: $cmd"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_fixture_pdf() {
|
||||
if [[ -f "$FIXTURE_PDF" ]]; then
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$(dirname "$FIXTURE_PDF")"
|
||||
cat >"$FIXTURE_PDF" <<'EOF'
|
||||
%PDF-1.0
|
||||
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Resources<<>>>>endobj
|
||||
xref
|
||||
0 4
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
trailer<</Size 4/Root 1 0 R>>
|
||||
startxref
|
||||
206
|
||||
%%EOF
|
||||
EOF
|
||||
}
|
||||
|
||||
unique_pdf_path() {
|
||||
local suffix="$1"
|
||||
local path="/tmp/vigilcare-p10-${suffix}-${RANDOM}.pdf"
|
||||
printf '%%PDF-1.4\nphase10-%s-%s\n%%%%EOF\n' "$suffix" "$(date +%s%N)" >"$path"
|
||||
printf '%s' "$path"
|
||||
}
|
||||
|
||||
http_code() {
|
||||
curl -sS -o /dev/null -w '%{http_code}' "$@"
|
||||
}
|
||||
|
||||
json_post() {
|
||||
local url="$1"
|
||||
local body="$2"
|
||||
local token="${3:-}"
|
||||
if [[ -n "$token" ]]; then
|
||||
curl -sS -X POST "$url" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$body"
|
||||
else
|
||||
curl -sS -X POST "$url" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$body"
|
||||
fi
|
||||
}
|
||||
|
||||
json_get() {
|
||||
local url="$1"
|
||||
local token="$2"
|
||||
curl -sS "$url" -H "Authorization: Bearer $token"
|
||||
}
|
||||
|
||||
login() {
|
||||
local username="$1"
|
||||
local password="${2:-password}"
|
||||
json_post "$API_URL/api/v1/auth/login" \
|
||||
"{\"username\":\"$username\",\"password\":\"$password\"}"
|
||||
}
|
||||
|
||||
extract_data_field() {
|
||||
local json="$1"
|
||||
local field="$2"
|
||||
jq -er ".data.$field // empty" <<<"$json"
|
||||
}
|
||||
|
||||
extract_bool_field() {
|
||||
local json="$1"
|
||||
local field="$2"
|
||||
jq -er ".data.$field | if . == null then empty else tostring end" <<<"$json"
|
||||
}
|
||||
|
||||
resolve_directory_ids() {
|
||||
local users_json patients_json
|
||||
users_json="$(json_get "$API_URL/api/v1/users?role=DATA_ENTRY_CLERK" "$INTAKE_TOKEN")"
|
||||
patients_json="$(json_get "$API_URL/api/v1/patients/search?q=Patient" "$INTAKE_TOKEN")"
|
||||
|
||||
ENTRY_CLERK1_ID="$(jq -er '.data[0].id // empty' <<<"$users_json" 2>/dev/null || true)"
|
||||
PATIENT1_ID="$(jq -er '.data[0].id // empty' <<<"$patients_json" 2>/dev/null || true)"
|
||||
|
||||
if [[ -n "$ENTRY_CLERK1_ID" ]]; then
|
||||
pass "resolved entry clerk ID for auto-assign test"
|
||||
else
|
||||
fail "resolved entry clerk ID for auto-assign test"
|
||||
fi
|
||||
|
||||
if [[ -n "$PATIENT1_ID" ]]; then
|
||||
pass "resolved patient ID for patient-linked cover sheet test"
|
||||
else
|
||||
fail "resolved patient ID for patient-linked cover sheet test"
|
||||
fi
|
||||
}
|
||||
|
||||
extract_error_code() {
|
||||
local json="$1"
|
||||
jq -er '.error.code // empty' <<<"$json" 2>/dev/null || true
|
||||
}
|
||||
|
||||
assert_api_reachable() {
|
||||
local code
|
||||
code="$(http_code "$API_URL/swagger/index.html" || true)"
|
||||
if [[ "$code" != "200" ]]; then
|
||||
log "ERROR: API not reachable at $API_URL (HTTP $code)."
|
||||
log "Start infrastructure with: docker compose up -d"
|
||||
log "Apply migrations with: dotnet ef database update --project VigilCareRecordsAPI"
|
||||
log "Start API with: dotnet run --project VigilCareRecordsAPI"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
compose_service_running() {
|
||||
local service="$1"
|
||||
"${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -qx "$service"
|
||||
}
|
||||
|
||||
psql_available() {
|
||||
[[ "$SKIP_DB_CHECKS" == "1" ]] && return 1
|
||||
compose_service_running postgres && return 0
|
||||
command -v psql >/dev/null 2>&1 && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
psql_query() {
|
||||
if [[ "$SKIP_DB_CHECKS" == "1" ]]; then
|
||||
return 1
|
||||
fi
|
||||
if compose_service_running postgres; then
|
||||
"${COMPOSE[@]}" exec -T postgres \
|
||||
psql -U "$PG_USER" -d "$PG_DB" -Atqc "$1"
|
||||
elif command -v psql >/dev/null 2>&1; then
|
||||
PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -Atqc "$1"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
upload_with_cover_sheet() {
|
||||
local token="$1"
|
||||
local code="$2"
|
||||
local pdf="$3"
|
||||
curl -sS -X POST "$API_URL/api/v1/digitization-batches" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-F "file=@${pdf};type=application/pdf" \
|
||||
-F "coverSheetCode=$code"
|
||||
}
|
||||
|
||||
upload_with_cover_sheet_status() {
|
||||
local token="$1"
|
||||
local code="$2"
|
||||
local pdf="$3"
|
||||
local body_file http_code_val
|
||||
body_file="$(mktemp)"
|
||||
http_code_val="$(curl -sS -o "$body_file" -w '%{http_code}' -X POST "$API_URL/api/v1/digitization-batches" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-F "file=@${pdf};type=application/pdf" \
|
||||
-F "coverSheetCode=$code")"
|
||||
cat "$body_file"
|
||||
rm -f "$body_file"
|
||||
printf '\n__HTTP_STATUS__:%s' "$http_code_val"
|
||||
}
|
||||
|
||||
generate_cover_sheets() {
|
||||
local token="$1"
|
||||
local body="$2"
|
||||
json_post "$API_URL/api/v1/cover-sheets/generate" "$body" "$token"
|
||||
}
|
||||
|
||||
test_authentication() {
|
||||
section "0. Authentication"
|
||||
|
||||
local intake_json admin_json entry_json
|
||||
intake_json="$(login intake1)"
|
||||
admin_json="$(login admin1)"
|
||||
entry_json="$(login entry1)"
|
||||
|
||||
INTAKE_TOKEN="$(extract_data_field "$intake_json" token)"
|
||||
ADMIN_TOKEN="$(extract_data_field "$admin_json" token)"
|
||||
ENTRY_TOKEN="$(extract_data_field "$entry_json" token)"
|
||||
|
||||
if [[ -z "$INTAKE_TOKEN" ]]; then
|
||||
log "ERROR: intake1 login failed."
|
||||
log "Response: ${intake_json:-<empty>}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$INTAKE_TOKEN" ]]; then
|
||||
pass "intake1 login returns JWT"
|
||||
else
|
||||
fail "intake1 login returns JWT"
|
||||
fi
|
||||
|
||||
if [[ -n "$ADMIN_TOKEN" ]]; then
|
||||
pass "admin1 login returns JWT"
|
||||
else
|
||||
fail "admin1 login returns JWT"
|
||||
fi
|
||||
|
||||
if [[ -n "$ENTRY_TOKEN" ]]; then
|
||||
pass "entry1 login returns JWT"
|
||||
else
|
||||
fail "entry1 login returns JWT"
|
||||
fi
|
||||
|
||||
resolve_directory_ids
|
||||
}
|
||||
|
||||
test_database_schema() {
|
||||
section "1. Database — cover_sheets table"
|
||||
|
||||
if ! psql_available; then
|
||||
log " SKIP: PostgreSQL checks (set VIGILCARE_SKIP_DB_CHECKS=0 and start postgres)"
|
||||
return
|
||||
fi
|
||||
|
||||
local table_exists index_code index_unused
|
||||
table_exists="$(psql_query "
|
||||
SELECT count(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'cover_sheets';
|
||||
")"
|
||||
index_code="$(psql_query "
|
||||
SELECT count(*)
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'cover_sheets' AND indexname = 'ix_cover_sheets_code';
|
||||
")"
|
||||
index_unused="$(psql_query "
|
||||
SELECT count(*)
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'cover_sheets' AND indexname = 'ix_cover_sheets_unused';
|
||||
")"
|
||||
|
||||
if [[ "$table_exists" == "1" ]]; then
|
||||
pass "cover_sheets table exists"
|
||||
else
|
||||
fail "cover_sheets table exists"
|
||||
fi
|
||||
|
||||
if [[ "$index_code" == "1" ]]; then
|
||||
pass "unique index ix_cover_sheets_code exists"
|
||||
else
|
||||
fail "unique index ix_cover_sheets_code exists"
|
||||
fi
|
||||
|
||||
if [[ "$index_unused" == "1" ]]; then
|
||||
pass "filtered index ix_cover_sheets_unused exists"
|
||||
else
|
||||
fail "filtered index ix_cover_sheets_unused exists"
|
||||
fi
|
||||
}
|
||||
|
||||
test_cover_sheet_generation() {
|
||||
section "2. Cover sheet generation (plan §1)"
|
||||
|
||||
local gen_json count unique_count bad_format
|
||||
gen_json="$(generate_cover_sheets "$INTAKE_TOKEN" \
|
||||
'{"count":5,"batchType":"VITALS_SHEET","track":"BACKFILL"}')"
|
||||
|
||||
if [[ "$(jq -er '.success' <<<"$gen_json")" == "true" ]]; then
|
||||
pass "POST /cover-sheets/generate returns success for intake1"
|
||||
else
|
||||
fail "POST /cover-sheets/generate returns success for intake1"
|
||||
log " response: $gen_json"
|
||||
return
|
||||
fi
|
||||
|
||||
count="$(jq '.data | length' <<<"$gen_json")"
|
||||
unique_count="$(jq '[.data[].code] | unique | length' <<<"$gen_json")"
|
||||
if [[ "$count" == "5" && "$unique_count" == "5" ]]; then
|
||||
pass "generate creates 5 cover sheets with unique codes"
|
||||
else
|
||||
fail "generate creates 5 cover sheets with unique codes (count=$count unique=$unique_count)"
|
||||
fi
|
||||
|
||||
bad_format="$(jq -r '[.data[].code | test("^VCR-CS-[0-9A-F]{8}$")] | all' <<<"$gen_json")"
|
||||
if [[ "$bad_format" == "true" ]]; then
|
||||
pass "all codes match VCR-CS-{8-hex} format"
|
||||
else
|
||||
fail "all codes match VCR-CS-{8-hex} format"
|
||||
fi
|
||||
|
||||
UPLOAD_CODE="$(jq -r '.data[0].code' <<<"$gen_json")"
|
||||
UPLOAD_COVER_SHEET_ID="$(jq -r '.data[0].id' <<<"$gen_json")"
|
||||
PDF_SHEET_IDS=($(jq -r '.data[0].id, .data[1].id' <<<"$gen_json"))
|
||||
|
||||
local entry_json entry_token entry_code
|
||||
entry_json="$(login entry1)"
|
||||
entry_token="$(extract_data_field "$entry_json" token)"
|
||||
entry_code="$(http_code -X POST "$API_URL/api/v1/cover-sheets/generate" \
|
||||
-H "Authorization: Bearer $entry_token" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"count":1,"batchType":"VITALS_SHEET","track":"BACKFILL"}')"
|
||||
if [[ "$entry_code" == "403" ]]; then
|
||||
pass "generate denied for DATA_ENTRY_CLERK (403)"
|
||||
else
|
||||
fail "generate denied for DATA_ENTRY_CLERK (403) — got HTTP $entry_code"
|
||||
fi
|
||||
|
||||
local admin_json
|
||||
admin_json="$(generate_cover_sheets "$ADMIN_TOKEN" \
|
||||
'{"count":1,"batchType":"LAB_RESULTS","track":"BACKFILL"}')"
|
||||
if [[ "$(jq -er '.success' <<<"$admin_json")" == "true" ]]; then
|
||||
pass "generate allowed for ADMINISTRATOR"
|
||||
else
|
||||
fail "generate allowed for ADMINISTRATOR"
|
||||
fi
|
||||
}
|
||||
|
||||
test_cover_sheet_lookup_and_list() {
|
||||
section "3. Cover sheet lookup and list (plan §2)"
|
||||
|
||||
if [[ -z "$UPLOAD_CODE" ]]; then
|
||||
fail "lookup requires generated cover sheet code"
|
||||
return
|
||||
fi
|
||||
|
||||
local lookup_json is_used batch_type
|
||||
lookup_json="$(json_get "$API_URL/api/v1/cover-sheets/lookup/$UPLOAD_CODE" "$INTAKE_TOKEN")"
|
||||
|
||||
if [[ "$(jq -er '.success' <<<"$lookup_json")" == "true" ]]; then
|
||||
pass "GET /cover-sheets/lookup/{code} returns success"
|
||||
else
|
||||
fail "GET /cover-sheets/lookup/{code} returns success"
|
||||
return
|
||||
fi
|
||||
|
||||
is_used="$(extract_bool_field "$lookup_json" isUsed)"
|
||||
batch_type="$(extract_data_field "$lookup_json" batchType)"
|
||||
if [[ "$is_used" == "false" ]]; then
|
||||
pass "lookup shows isUsed=false before upload"
|
||||
else
|
||||
fail "lookup shows isUsed=false before upload (isUsed=$is_used)"
|
||||
fi
|
||||
|
||||
if [[ "$batch_type" == "VITALS_SHEET" ]]; then
|
||||
pass "lookup returns batchType VITALS_SHEET"
|
||||
else
|
||||
fail "lookup returns batchType VITALS_SHEET (got $batch_type)"
|
||||
fi
|
||||
|
||||
local unknown_json unknown_code
|
||||
unknown_json="$(json_get "$API_URL/api/v1/cover-sheets/lookup/VCR-CS-DEADBEEF" "$INTAKE_TOKEN")"
|
||||
unknown_code="$(extract_error_code "$unknown_json")"
|
||||
if [[ "$unknown_code" == "COVER_SHEET_NOT_FOUND" ]]; then
|
||||
pass "lookup unknown code returns COVER_SHEET_NOT_FOUND"
|
||||
else
|
||||
fail "lookup unknown code returns COVER_SHEET_NOT_FOUND (got ${unknown_code:-<none>})"
|
||||
fi
|
||||
|
||||
local list_json unused_count
|
||||
list_json="$(json_get "$API_URL/api/v1/cover-sheets?isUsed=false&page=1&pageSize=20" "$INTAKE_TOKEN")"
|
||||
unused_count="$(jq '.data | length' <<<"$list_json")"
|
||||
if [[ "$(jq -er '.success' <<<"$list_json")" == "true" && "$unused_count" -ge 5 ]]; then
|
||||
pass "GET /cover-sheets?isUsed=false lists unused sheets"
|
||||
else
|
||||
fail "GET /cover-sheets?isUsed=false lists unused sheets (count=$unused_count)"
|
||||
fi
|
||||
}
|
||||
|
||||
test_pdf_generation() {
|
||||
section "4. Cover sheet PDF generation (plan Step 5)"
|
||||
|
||||
if [[ -z "$UPLOAD_COVER_SHEET_ID" || ${#PDF_SHEET_IDS[@]} -lt 2 ]]; then
|
||||
fail "PDF tests require generated cover sheet IDs"
|
||||
return
|
||||
fi
|
||||
|
||||
local pdf_tmp headers_file pdf_code content_type pdf_header
|
||||
pdf_tmp="$(mktemp)"
|
||||
headers_file="$(mktemp)"
|
||||
pdf_code="$(curl -sS -D "$headers_file" -o "$pdf_tmp" -w '%{http_code}' -X POST \
|
||||
"$API_URL/api/v1/cover-sheets/${UPLOAD_COVER_SHEET_ID}/pdf" \
|
||||
-H "Authorization: Bearer $INTAKE_TOKEN")"
|
||||
content_type="$(awk -F': ' 'tolower($1)=="content-type"{print $2}' "$headers_file" | tr -d '\r' | head -1)"
|
||||
rm -f "$headers_file"
|
||||
|
||||
if [[ "$pdf_code" == "200" ]]; then
|
||||
pass "POST /cover-sheets/{id}/pdf returns 200"
|
||||
else
|
||||
fail "POST /cover-sheets/{id}/pdf returns 200 (got HTTP $pdf_code)"
|
||||
fi
|
||||
|
||||
if [[ "$content_type" == application/pdf* ]]; then
|
||||
pass "single PDF response Content-Type is application/pdf"
|
||||
else
|
||||
fail "single PDF response Content-Type is application/pdf (got ${content_type:-<none>})"
|
||||
fi
|
||||
|
||||
pdf_header="$(head -c 8 "$pdf_tmp" || true)"
|
||||
if [[ "$pdf_header" == %PDF-1.* ]]; then
|
||||
pass "single PDF body starts with %PDF header"
|
||||
else
|
||||
fail "single PDF body starts with %PDF header"
|
||||
fi
|
||||
|
||||
if grep -aq "$UPLOAD_CODE" "$pdf_tmp" 2>/dev/null; then
|
||||
pass "single PDF embeds cover sheet code text"
|
||||
else
|
||||
fail "single PDF embeds cover sheet code text"
|
||||
fi
|
||||
rm -f "$pdf_tmp"
|
||||
|
||||
local batch_pdf_tmp batch_pdf_code
|
||||
batch_pdf_tmp="$(mktemp)"
|
||||
batch_pdf_code="$(curl -sS -o "$batch_pdf_tmp" -w '%{http_code}' -X POST \
|
||||
"$API_URL/api/v1/cover-sheets/batch-pdf" \
|
||||
-H "Authorization: Bearer $INTAKE_TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"coverSheetIds\":[\"${PDF_SHEET_IDS[0]}\",\"${PDF_SHEET_IDS[1]}\"]}")"
|
||||
|
||||
if [[ "$batch_pdf_code" == "200" ]]; then
|
||||
pass "POST /cover-sheets/batch-pdf returns 200"
|
||||
else
|
||||
fail "POST /cover-sheets/batch-pdf returns 200 (got HTTP $batch_pdf_code)"
|
||||
fi
|
||||
|
||||
if grep -aq '/Count 2' "$batch_pdf_tmp" 2>/dev/null || strings "$batch_pdf_tmp" | grep -q '/Count 2'; then
|
||||
pass "batch PDF contains two pages (/Count 2)"
|
||||
else
|
||||
fail "batch PDF contains two pages (/Count 2)"
|
||||
fi
|
||||
rm -f "$batch_pdf_tmp"
|
||||
}
|
||||
|
||||
test_barcode_assisted_upload() {
|
||||
section "5. Barcode-assisted upload (plan §3)"
|
||||
|
||||
if [[ -z "$UPLOAD_CODE" ]]; then
|
||||
fail "barcode upload requires generated cover sheet code"
|
||||
return
|
||||
fi
|
||||
|
||||
local pdf upload_json batch_type track status entered_by
|
||||
pdf="$(unique_pdf_path upload)"
|
||||
upload_json="$(upload_with_cover_sheet "$INTAKE_TOKEN" "$UPLOAD_CODE" "$pdf")"
|
||||
rm -f "$pdf"
|
||||
|
||||
if [[ "$(jq -er '.success' <<<"$upload_json")" == "true" ]]; then
|
||||
pass "POST /digitization-batches with coverSheetCode creates batch"
|
||||
else
|
||||
fail "POST /digitization-batches with coverSheetCode creates batch"
|
||||
log " response: $upload_json"
|
||||
return
|
||||
fi
|
||||
|
||||
batch_type="$(extract_data_field "$upload_json" batchType)"
|
||||
track="$(extract_data_field "$upload_json" track)"
|
||||
status="$(extract_data_field "$upload_json" status)"
|
||||
UPLOAD_BATCH_ID="$(extract_data_field "$upload_json" id)"
|
||||
|
||||
if [[ "$batch_type" == "VITALS_SHEET" && "$track" == "BACKFILL" ]]; then
|
||||
pass "batch inherits batchType and track from cover sheet"
|
||||
else
|
||||
fail "batch inherits batchType and track from cover sheet (type=$batch_type track=$track)"
|
||||
fi
|
||||
|
||||
local redeemed_json redeemed_used redeemed_batch_id
|
||||
redeemed_json="$(json_get "$API_URL/api/v1/cover-sheets/lookup/$UPLOAD_CODE" "$INTAKE_TOKEN")"
|
||||
redeemed_used="$(extract_bool_field "$redeemed_json" isUsed)"
|
||||
redeemed_batch_id="$(extract_data_field "$redeemed_json" batchId)"
|
||||
|
||||
if [[ "$redeemed_used" == "true" && "$redeemed_batch_id" == "$UPLOAD_BATCH_ID" ]]; then
|
||||
pass "cover sheet redeemed and linked to batch after upload"
|
||||
else
|
||||
fail "cover sheet redeemed and linked to batch after upload"
|
||||
fi
|
||||
|
||||
if psql_available && [[ -n "$UPLOAD_COVER_SHEET_ID" ]]; then
|
||||
local db_used db_batch
|
||||
db_used="$(psql_query "
|
||||
SELECT is_used FROM cover_sheets WHERE id = '$UPLOAD_COVER_SHEET_ID';
|
||||
")"
|
||||
db_batch="$(psql_query "
|
||||
SELECT batch_id FROM cover_sheets WHERE id = '$UPLOAD_COVER_SHEET_ID';
|
||||
")"
|
||||
if [[ "$db_used" == "t" && "$db_batch" == "$UPLOAD_BATCH_ID" ]]; then
|
||||
pass "database row shows is_used=true with batch_id"
|
||||
else
|
||||
fail "database row shows is_used=true with batch_id"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
test_cover_sheet_reuse_and_unknown() {
|
||||
section "6. Reuse prevention and unknown code (plan §4)"
|
||||
|
||||
if [[ -z "$UPLOAD_CODE" ]]; then
|
||||
fail "reuse test requires uploaded cover sheet code"
|
||||
return
|
||||
fi
|
||||
|
||||
local pdf reuse_response reuse_status reuse_code unknown_response unknown_status unknown_code
|
||||
pdf="$(unique_pdf_path reuse)"
|
||||
reuse_response="$(upload_with_cover_sheet_status "$INTAKE_TOKEN" "$UPLOAD_CODE" "$pdf")"
|
||||
rm -f "$pdf"
|
||||
|
||||
reuse_status="${reuse_response##*__HTTP_STATUS__:}"
|
||||
reuse_response="${reuse_response%$'\n'__HTTP_STATUS__:*}"
|
||||
reuse_code="$(extract_error_code "$reuse_response")"
|
||||
|
||||
if [[ "$reuse_status" == "409" && "$reuse_code" == "COVER_SHEET_ALREADY_USED" ]]; then
|
||||
pass "reused cover sheet code returns 409 COVER_SHEET_ALREADY_USED"
|
||||
else
|
||||
fail "reused cover sheet code returns 409 COVER_SHEET_ALREADY_USED (HTTP $reuse_status code=${reuse_code:-<none>})"
|
||||
fi
|
||||
|
||||
pdf="$(unique_pdf_path unknown)"
|
||||
unknown_response="$(upload_with_cover_sheet_status "$INTAKE_TOKEN" "VCR-CS-DEADBEEF" "$pdf")"
|
||||
rm -f "$pdf"
|
||||
|
||||
unknown_status="${unknown_response##*__HTTP_STATUS__:}"
|
||||
unknown_response="${unknown_response%$'\n'__HTTP_STATUS__:*}"
|
||||
unknown_code="$(extract_error_code "$unknown_response")"
|
||||
|
||||
if [[ "$unknown_status" == "404" && "$unknown_code" == "COVER_SHEET_NOT_FOUND" ]]; then
|
||||
pass "unknown cover sheet code on upload returns 404 COVER_SHEET_NOT_FOUND"
|
||||
else
|
||||
fail "unknown cover sheet code on upload returns 404 COVER_SHEET_NOT_FOUND (HTTP $unknown_status code=${unknown_code:-<none>})"
|
||||
fi
|
||||
}
|
||||
|
||||
test_auto_assign_cover_sheet() {
|
||||
section "7. Pre-assigned cover sheet auto-assigns batch"
|
||||
|
||||
if [[ -z "$ENTRY_CLERK1_ID" ]]; then
|
||||
fail "auto-assign test skipped — no entry clerk ID"
|
||||
return
|
||||
fi
|
||||
|
||||
local gen_json assign_code pdf upload_json status entered_by
|
||||
gen_json="$(generate_cover_sheets "$INTAKE_TOKEN" \
|
||||
"{\"count\":1,\"batchType\":\"LAB_RESULTS\",\"track\":\"BACKFILL\",\"assignToUserId\":\"$ENTRY_CLERK1_ID\"}")"
|
||||
|
||||
if [[ "$(jq -er '.success' <<<"$gen_json")" != "true" ]]; then
|
||||
fail "generate pre-assigned cover sheet"
|
||||
return
|
||||
fi
|
||||
|
||||
assign_code="$(jq -r '.data[0].code' <<<"$gen_json")"
|
||||
pdf="$(unique_pdf_path assign)"
|
||||
upload_json="$(upload_with_cover_sheet "$INTAKE_TOKEN" "$assign_code" "$pdf")"
|
||||
rm -f "$pdf"
|
||||
|
||||
status="$(extract_data_field "$upload_json" status)"
|
||||
entered_by="$(extract_data_field "$upload_json" enteredByUserId)"
|
||||
|
||||
if [[ "$status" == "IN_ENTRY" ]]; then
|
||||
pass "pre-assigned upload transitions batch to IN_ENTRY"
|
||||
else
|
||||
fail "pre-assigned upload transitions batch to IN_ENTRY (status=$status)"
|
||||
fi
|
||||
|
||||
if [[ "$entered_by" == "$ENTRY_CLERK1_ID" ]]; then
|
||||
pass "pre-assigned upload sets enteredByUserId to entry clerk"
|
||||
else
|
||||
fail "pre-assigned upload sets enteredByUserId to entry clerk (got $entered_by)"
|
||||
fi
|
||||
}
|
||||
|
||||
test_patient_linked_cover_sheet() {
|
||||
section "8. Patient-linked cover sheet populates batch patient"
|
||||
|
||||
if [[ -z "$PATIENT1_ID" ]]; then
|
||||
fail "patient-linked test skipped — no patient ID"
|
||||
return
|
||||
fi
|
||||
|
||||
local gen_json patient_code pdf upload_json patient_id
|
||||
gen_json="$(generate_cover_sheets "$INTAKE_TOKEN" \
|
||||
"{\"count\":1,\"batchType\":\"MEDICATION_LIST\",\"track\":\"BACKFILL\",\"patientId\":\"$PATIENT1_ID\"}")"
|
||||
|
||||
if [[ "$(jq -er '.success' <<<"$gen_json")" != "true" ]]; then
|
||||
fail "generate patient-linked cover sheet"
|
||||
return
|
||||
fi
|
||||
|
||||
patient_code="$(jq -r '.data[0].code' <<<"$gen_json")"
|
||||
pdf="$(unique_pdf_path patient)"
|
||||
upload_json="$(upload_with_cover_sheet "$INTAKE_TOKEN" "$patient_code" "$pdf")"
|
||||
rm -f "$pdf"
|
||||
|
||||
patient_id="$(extract_data_field "$upload_json" patientId)"
|
||||
if [[ "$patient_id" == "$PATIENT1_ID" ]]; then
|
||||
pass "patient-linked cover sheet sets batch patientId"
|
||||
else
|
||||
fail "patient-linked cover sheet sets batch patientId (got $patient_id)"
|
||||
fi
|
||||
}
|
||||
|
||||
test_integration_tests() {
|
||||
section "9. dotnet integration tests — CoverSheetBatchTests, CoverSheetPdfTests"
|
||||
|
||||
if [[ "$SKIP_TEST_CHECKS" == "1" ]]; then
|
||||
log " SKIP: dotnet integration tests (VIGILCARE_SKIP_TEST_CHECKS=1)"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! command -v dotnet >/dev/null 2>&1; then
|
||||
log " SKIP: dotnet not installed"
|
||||
return
|
||||
fi
|
||||
|
||||
if dotnet test "$REPO_ROOT/VigilCareRecordsAPI.Tests/VigilCareRecordsAPI.Tests.csproj" \
|
||||
--filter "FullyQualifiedName~CoverSheet" \
|
||||
--no-restore >/tmp/vigilcare-p10-tests.log 2>&1; then
|
||||
pass "CoverSheet integration tests passed"
|
||||
else
|
||||
fail "CoverSheet integration tests passed"
|
||||
log " see /tmp/vigilcare-p10-tests.log"
|
||||
fi
|
||||
}
|
||||
|
||||
print_manual_ui_checklist() {
|
||||
section "10. Manual Vue UI checks (plan §5)"
|
||||
log " Login as intake1 → /cover-sheets"
|
||||
log " - Generate 5 VITALS_SHEET covers"
|
||||
log " - Print Cover Sheets opens PDF in new tab"
|
||||
log " - Cover sheet list shows unused sheets"
|
||||
log " Navigate to /intake"
|
||||
log " - Scan/type a cover sheet code (Enter triggers lookup)"
|
||||
log " - Lookup auto-fills batch type, track, patient"
|
||||
log " - Upload with Cover Sheet marks sheet as Used with linked batch ID"
|
||||
}
|
||||
|
||||
main() {
|
||||
require_cmd curl
|
||||
require_cmd jq
|
||||
ensure_fixture_pdf
|
||||
|
||||
log "VigilCare Records — Phase 10 verification"
|
||||
log "API: $API_URL"
|
||||
|
||||
assert_api_reachable
|
||||
|
||||
test_authentication
|
||||
test_database_schema
|
||||
test_cover_sheet_generation
|
||||
test_cover_sheet_lookup_and_list
|
||||
test_pdf_generation
|
||||
test_barcode_assisted_upload
|
||||
test_cover_sheet_reuse_and_unknown
|
||||
test_auto_assign_cover_sheet
|
||||
test_patient_linked_cover_sheet
|
||||
test_integration_tests
|
||||
print_manual_ui_checklist
|
||||
|
||||
log ""
|
||||
log "Results: $PASS_COUNT passed, $FAIL_COUNT failed"
|
||||
if (( FAIL_COUNT > 0 )); then
|
||||
log "Failed checks:"
|
||||
for item in "${FAILED_TESTS[@]}"; do
|
||||
log " - $item"
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "All Phase 10 API verification checks passed."
|
||||
log "Complete the manual Vue UI checklist above if not already done."
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Generated
+1609
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.3.0",
|
||||
@@ -16,14 +18,18 @@
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"@types/node": "^24.13.2",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.0",
|
||||
"vitest": "^4.1.9",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import EntryForm from '@/components/EntryForm.vue'
|
||||
import { useBatchStore } from '@/stores/batches'
|
||||
import type { BatchDetailResponse } from '@/types'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
del: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse {
|
||||
return {
|
||||
id: 'b1',
|
||||
status: 'IN_ENTRY',
|
||||
batchType: 'VITALS',
|
||||
track: 'TRACK_A',
|
||||
patientId: null,
|
||||
documentRef: 'docs/scan.pdf',
|
||||
documentUrl: null,
|
||||
enableRetroactiveAlerts: false,
|
||||
enteredByUserId: 'u1',
|
||||
verifiedByUserId: null,
|
||||
approvedByUserId: null,
|
||||
rejectionReason: null,
|
||||
promotedAt: null,
|
||||
promotionEncounterId: null,
|
||||
supersedesBatchId: null,
|
||||
clinicianAttestation: false,
|
||||
createdAt: '2026-06-27T10:00:00Z',
|
||||
updatedAt: '2026-06-27T10:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('EntryForm', () => {
|
||||
it('renders patient demographics fields', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Patient Demographics')
|
||||
expect(wrapper.text()).toContain('Full Name')
|
||||
expect(wrapper.text()).toContain('Date of Birth')
|
||||
expect(wrapper.text()).toContain('Sex')
|
||||
expect(wrapper.text()).toContain('Blood Type')
|
||||
expect(wrapper.text()).toContain('Emergency Contact')
|
||||
})
|
||||
|
||||
it('renders encounter context fields', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Encounter Context')
|
||||
expect(wrapper.text()).toContain('Admission Date')
|
||||
expect(wrapper.text()).toContain('Department')
|
||||
expect(wrapper.text()).toContain('Room / Bed')
|
||||
expect(wrapper.text()).toContain('Admission Reason')
|
||||
})
|
||||
|
||||
it('renders observations section with add button', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Observations')
|
||||
expect(wrapper.text()).toContain('+ Add Observation')
|
||||
})
|
||||
|
||||
it('renders submit button', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
const submitBtn = wrapper.find('button')
|
||||
const buttons = wrapper.findAll('button')
|
||||
const submitButton = buttons.find((b) => b.text().includes('Submit for Verification'))
|
||||
expect(submitButton).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays batch status', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch({ status: 'IN_ENTRY' }), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('IN ENTRY')
|
||||
})
|
||||
|
||||
describe('conditional sections by batch type', () => {
|
||||
it('hides allergies section for VITALS batch', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch({ batchType: 'VITALS' }), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).not.toContain('Allergies')
|
||||
})
|
||||
|
||||
it('shows allergies section for ALLERGY_UPDATE batch', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch({ batchType: 'ALLERGY_UPDATE' }), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Allergies')
|
||||
expect(wrapper.text()).toContain('No known allergies')
|
||||
})
|
||||
|
||||
it('hides medications section for VITALS batch', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch({ batchType: 'VITALS' }), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).not.toContain('Medications')
|
||||
})
|
||||
|
||||
it('shows medications section for MEDICATION_LIST batch', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch({ batchType: 'MEDICATION_LIST' }), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Medications')
|
||||
expect(wrapper.text()).toContain('No active medications')
|
||||
})
|
||||
|
||||
it('shows both allergies and medications for MIXED batch', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch({ batchType: 'MIXED' }), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Allergies')
|
||||
expect(wrapper.text()).toContain('Medications')
|
||||
})
|
||||
|
||||
it('hides encounter summary fields for VITALS batch', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch({ batchType: 'VITALS' }), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).not.toContain('Encounter Status')
|
||||
expect(wrapper.text()).not.toContain('Discharge Diagnosis')
|
||||
})
|
||||
|
||||
it('shows encounter summary fields for ENCOUNTER_SUMMARY batch', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch({ batchType: 'ENCOUNTER_SUMMARY' }), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Encounter Status')
|
||||
expect(wrapper.text()).toContain('Discharge Diagnosis')
|
||||
})
|
||||
})
|
||||
|
||||
describe('draft loading', () => {
|
||||
it('populates patient fields from draft', async () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.currentDraft = {
|
||||
patient: {
|
||||
id: 'dp1',
|
||||
batchId: 'b1',
|
||||
fullName: 'John Doe',
|
||||
dateOfBirth: '1990-05-15',
|
||||
sex: 'male',
|
||||
bloodType: 'O+',
|
||||
emergencyContact: '555-1234',
|
||||
allergiesJson: null,
|
||||
noKnownAllergies: false,
|
||||
medicationsJson: null,
|
||||
noActiveMedications: false,
|
||||
},
|
||||
encounter: null,
|
||||
observations: [],
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const nameInput = wrapper.find('input[type="text"]')
|
||||
expect(nameInput.element.value).toBe('John Doe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('save on blur', () => {
|
||||
it('calls saveDraftPatient on patient field blur', async () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.saveDraftPatient = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const nameInput = wrapper.find('input[type="text"]')
|
||||
await nameInput.setValue('Jane Doe')
|
||||
await nameInput.trigger('blur')
|
||||
|
||||
expect(store.saveDraftPatient).toHaveBeenCalledWith(
|
||||
'b1',
|
||||
expect.objectContaining({ fullName: 'Jane Doe' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('calls saveDraftEncounter on encounter field blur', async () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.saveDraftEncounter = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const roomInput = wrapper.findAll('input[type="text"]').find((i) => {
|
||||
const label = i.element.closest('div')?.querySelector('label')
|
||||
return label?.textContent?.includes('Room')
|
||||
})
|
||||
if (roomInput) {
|
||||
await roomInput.setValue('4B-01')
|
||||
await roomInput.trigger('blur')
|
||||
expect(store.saveDraftEncounter).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('submit for verification', () => {
|
||||
it('calls submitForVerification on click', async () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.submitForVerification = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const submitBtn = wrapper.findAll('button').find((b) => b.text().includes('Submit for Verification'))
|
||||
await submitBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(store.submitForVerification).toHaveBeenCalledWith('b1')
|
||||
})
|
||||
|
||||
it('displays error message on submit failure', async () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.submitForVerification = vi.fn().mockRejectedValue(new Error('BATCH_INCOMPLETE'))
|
||||
|
||||
const submitBtn = wrapper.findAll('button').find((b) => b.text().includes('Submit for Verification'))
|
||||
await submitBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('BATCH_INCOMPLETE')
|
||||
})
|
||||
|
||||
it('shows submitting state on button', async () => {
|
||||
let resolvePromise: () => void
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.submitForVerification = vi.fn().mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const submitBtn = wrapper.findAll('button').find((b) => b.text().includes('Submit for Verification'))
|
||||
await submitBtn!.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Submitting...')
|
||||
|
||||
resolvePromise!()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Submit for Verification')
|
||||
})
|
||||
})
|
||||
|
||||
describe('observations', () => {
|
||||
it('calls addObservation on add button click', async () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.addObservation = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const addBtn = wrapper.findAll('button').find((b) => b.text().includes('+ Add Observation'))
|
||||
await addBtn!.trigger('click')
|
||||
|
||||
expect(store.addObservation).toHaveBeenCalledWith('b1', expect.objectContaining({
|
||||
observationCode: '',
|
||||
value: 0,
|
||||
unit: '',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('blood type options', () => {
|
||||
it('renders all 8 blood type options plus empty', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
const selects = wrapper.findAll('select')
|
||||
const bloodTypeSelect = selects.find((s) => {
|
||||
const opts = s.findAll('option')
|
||||
return opts.some((o) => o.text() === 'A+')
|
||||
})
|
||||
expect(bloodTypeSelect).toBeTruthy()
|
||||
const options = bloodTypeSelect!.findAll('option')
|
||||
expect(options.length).toBe(9) // "Unknown" + 8 blood types
|
||||
})
|
||||
})
|
||||
|
||||
describe('department options', () => {
|
||||
it('renders department dropdown with clinical departments', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
const selects = wrapper.findAll('select')
|
||||
const deptSelect = selects.find((s) => {
|
||||
const opts = s.findAll('option')
|
||||
return opts.some((o) => o.text() === 'ICU')
|
||||
})
|
||||
expect(deptSelect).toBeTruthy()
|
||||
const options = deptSelect!.findAll('option')
|
||||
expect(options.length).toBeGreaterThan(10)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ObservationRow from '@/components/ObservationRow.vue'
|
||||
import type { DraftObservation } from '@/types'
|
||||
|
||||
function makeObservation(overrides: Partial<DraftObservation> = {}): DraftObservation {
|
||||
return {
|
||||
id: 'obs-1',
|
||||
batchId: 'b1',
|
||||
observationCode: 'HEART_RATE',
|
||||
value: 72,
|
||||
unit: 'bpm',
|
||||
recordedAt: '2026-06-27T10:00:00Z',
|
||||
note: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ObservationRow', () => {
|
||||
it('renders observation code options', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation() },
|
||||
})
|
||||
const options = wrapper.findAll('select option')
|
||||
const values = options.map((o) => o.element.value)
|
||||
expect(values).toContain('HEART_RATE')
|
||||
expect(values).toContain('TEMP_C')
|
||||
expect(values).toContain('BP_SYSTOLIC')
|
||||
expect(values).toContain('SPO2')
|
||||
expect(values).toContain('POTASSIUM_MEQ_L')
|
||||
})
|
||||
|
||||
it('renders observation values in inputs', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation({ value: 98.6, unit: '°F' }) },
|
||||
})
|
||||
const numberInput = wrapper.find('input[type="number"]')
|
||||
expect(numberInput.element.value).toBe('98.6')
|
||||
|
||||
const textInputs = wrapper.findAll('input[type="text"]')
|
||||
const unitInput = textInputs[0]
|
||||
expect(unitInput.element.value).toBe('°F')
|
||||
})
|
||||
|
||||
it('emits update event on code change', async () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation() },
|
||||
})
|
||||
const select = wrapper.find('select')
|
||||
await select.setValue('TEMP_C')
|
||||
|
||||
expect(wrapper.emitted('update')).toBeTruthy()
|
||||
expect(wrapper.emitted('update')![0]).toEqual(['observationCode', 'TEMP_C'])
|
||||
})
|
||||
|
||||
it('emits update event on value change', async () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation() },
|
||||
})
|
||||
const numberInput = wrapper.find('input[type="number"]')
|
||||
await numberInput.setValue('80')
|
||||
await numberInput.trigger('change')
|
||||
|
||||
expect(wrapper.emitted('update')).toBeTruthy()
|
||||
const emitted = wrapper.emitted('update')![0]
|
||||
expect(emitted[0]).toBe('value')
|
||||
expect(emitted[1]).toBe(80)
|
||||
})
|
||||
|
||||
it('shows delete button in entry mode (not readonly)', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation() },
|
||||
})
|
||||
const deleteBtn = wrapper.find('button')
|
||||
expect(deleteBtn.exists()).toBe(true)
|
||||
expect(deleteBtn.text()).toBe('Remove')
|
||||
})
|
||||
|
||||
it('emits delete event on remove click', async () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation({ id: 'obs-42' }) },
|
||||
})
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('delete')).toBeTruthy()
|
||||
expect(wrapper.emitted('delete')![0]).toEqual(['obs-42'])
|
||||
})
|
||||
|
||||
it('disables inputs in readonly mode', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation(), readonly: true },
|
||||
})
|
||||
const select = wrapper.find('select')
|
||||
expect(select.element.disabled).toBe(true)
|
||||
|
||||
const numberInput = wrapper.find('input[type="number"]')
|
||||
expect(numberInput.element.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('hides delete button in readonly mode', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation(), readonly: true },
|
||||
})
|
||||
const button = wrapper.find('button')
|
||||
expect(button.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows verification checkbox when showVerified is true', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: {
|
||||
observation: makeObservation(),
|
||||
readonly: true,
|
||||
showVerified: true,
|
||||
verified: false,
|
||||
},
|
||||
})
|
||||
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||
expect(checkbox.exists()).toBe(true)
|
||||
expect(checkbox.element.checked).toBe(false)
|
||||
})
|
||||
|
||||
it('reflects verified state in checkbox', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: {
|
||||
observation: makeObservation(),
|
||||
readonly: true,
|
||||
showVerified: true,
|
||||
verified: true,
|
||||
},
|
||||
})
|
||||
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||
expect(checkbox.element.checked).toBe(true)
|
||||
})
|
||||
|
||||
it('emits verify event when checkbox is toggled', async () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: {
|
||||
observation: makeObservation({ id: 'obs-99' }),
|
||||
readonly: true,
|
||||
showVerified: true,
|
||||
verified: false,
|
||||
},
|
||||
})
|
||||
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||
await checkbox.setValue(true)
|
||||
|
||||
expect(wrapper.emitted('verify')).toBeTruthy()
|
||||
expect(wrapper.emitted('verify')![0]).toEqual(['obs-99', true])
|
||||
})
|
||||
|
||||
it('hides verification checkbox when showVerified is false', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation(), showVerified: false },
|
||||
})
|
||||
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||
expect(checkbox.exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PatientSearch from '@/components/PatientSearch.vue'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
get: vi.fn(),
|
||||
}))
|
||||
|
||||
import { get } from '@/api/client'
|
||||
|
||||
const mockedGet = vi.mocked(get)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
describe('PatientSearch', () => {
|
||||
it('renders a search input', () => {
|
||||
const wrapper = mount(PatientSearch)
|
||||
const input = wrapper.find('input')
|
||||
expect(input.exists()).toBe(true)
|
||||
expect(input.attributes('placeholder')).toContain('Search')
|
||||
})
|
||||
|
||||
it('does not search when query is less than 2 characters', async () => {
|
||||
const wrapper = mount(PatientSearch)
|
||||
const input = wrapper.find('input')
|
||||
|
||||
await input.setValue('J')
|
||||
await input.trigger('input')
|
||||
vi.advanceTimersByTime(400)
|
||||
|
||||
expect(mockedGet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('searches after debounce when query is 2+ characters', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(PatientSearch)
|
||||
const input = wrapper.find('input')
|
||||
|
||||
await input.setValue('Jane')
|
||||
await input.trigger('input')
|
||||
|
||||
expect(mockedGet).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(mockedGet).toHaveBeenCalledWith('patients/search', { q: 'Jane' })
|
||||
})
|
||||
|
||||
it('shows results dropdown after search', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: [
|
||||
{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' },
|
||||
{ id: 'p2', fullName: 'Jane Smith', mrn: 'MRN-002' },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(PatientSearch)
|
||||
await wrapper.find('input').setValue('Jane')
|
||||
await wrapper.find('input').trigger('input')
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.runAllTimersAsync()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const items = wrapper.findAll('li')
|
||||
expect(items.length).toBe(2)
|
||||
expect(items[0].text()).toContain('Jane Doe')
|
||||
expect(items[0].text()).toContain('MRN-001')
|
||||
})
|
||||
|
||||
it('emits update:modelValue on patient selection', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(PatientSearch)
|
||||
await wrapper.find('input').setValue('Jane')
|
||||
await wrapper.find('input').trigger('input')
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.runAllTimersAsync()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await wrapper.find('li').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['p1'])
|
||||
})
|
||||
|
||||
it('shows selected patient name after selection', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(PatientSearch)
|
||||
await wrapper.find('input').setValue('Jane')
|
||||
await wrapper.find('input').trigger('input')
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.runAllTimersAsync()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await wrapper.find('li').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Selected: Jane Doe')
|
||||
})
|
||||
|
||||
it('clears results list on patient selection', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(PatientSearch)
|
||||
await wrapper.find('input').setValue('Jane')
|
||||
await wrapper.find('input').trigger('input')
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.runAllTimersAsync()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await wrapper.find('li').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.findAll('li').length).toBe(0)
|
||||
})
|
||||
|
||||
it('sets input value to patient name on selection', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(PatientSearch)
|
||||
const input = wrapper.find('input')
|
||||
await input.setValue('Jane')
|
||||
await input.trigger('input')
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.runAllTimersAsync()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await wrapper.find('li').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(input.element.value).toBe('Jane Doe')
|
||||
})
|
||||
|
||||
it('clears results on API error', async () => {
|
||||
mockedGet.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const wrapper = mount(PatientSearch)
|
||||
await wrapper.find('input').setValue('Jane')
|
||||
await wrapper.find('input').trigger('input')
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.runAllTimersAsync()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.findAll('li').length).toBe(0)
|
||||
})
|
||||
|
||||
it('debounces multiple rapid inputs', async () => {
|
||||
mockedGet.mockResolvedValue({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: [],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(PatientSearch)
|
||||
const input = wrapper.find('input')
|
||||
|
||||
await input.setValue('Ja')
|
||||
await input.trigger('input')
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
await input.setValue('Jan')
|
||||
await input.trigger('input')
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
await input.setValue('Jane')
|
||||
await input.trigger('input')
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(mockedGet).toHaveBeenCalledTimes(1)
|
||||
expect(mockedGet).toHaveBeenCalledWith('patients/search', { q: 'Jane' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,406 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import VerificationForm from '@/components/VerificationForm.vue'
|
||||
import { useBatchStore } from '@/stores/batches'
|
||||
import type { BatchDetailResponse } from '@/types'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
del: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse {
|
||||
return {
|
||||
id: 'b1',
|
||||
status: 'PENDING_VERIFICATION',
|
||||
batchType: 'VITALS',
|
||||
track: 'TRACK_A',
|
||||
patientId: 'p1',
|
||||
documentRef: 'docs/scan.pdf',
|
||||
documentUrl: null,
|
||||
enableRetroactiveAlerts: false,
|
||||
enteredByUserId: 'u1',
|
||||
verifiedByUserId: null,
|
||||
approvedByUserId: null,
|
||||
rejectionReason: null,
|
||||
promotedAt: null,
|
||||
promotionEncounterId: null,
|
||||
supersedesBatchId: null,
|
||||
clinicianAttestation: false,
|
||||
createdAt: '2026-06-27T10:00:00Z',
|
||||
updatedAt: '2026-06-27T10:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mountWithDraft(batchOverrides: Partial<BatchDetailResponse> = {}) {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(batchOverrides), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.currentDraft = {
|
||||
patient: {
|
||||
id: 'dp1',
|
||||
batchId: 'b1',
|
||||
fullName: 'Jane Doe',
|
||||
dateOfBirth: '1990-05-15',
|
||||
sex: 'female',
|
||||
bloodType: 'A+',
|
||||
emergencyContact: '555-1234',
|
||||
allergiesJson: null,
|
||||
noKnownAllergies: false,
|
||||
medicationsJson: null,
|
||||
noActiveMedications: false,
|
||||
},
|
||||
encounter: {
|
||||
id: 'de1',
|
||||
batchId: 'b1',
|
||||
admissionDate: '2026-06-20T08:00:00',
|
||||
department: 'ICU',
|
||||
roomBed: '3A-12',
|
||||
admissionReason: 'Chest pain',
|
||||
dischargeDiagnosis: null,
|
||||
status: null,
|
||||
},
|
||||
observations: [
|
||||
{
|
||||
id: 'obs-1',
|
||||
batchId: 'b1',
|
||||
observationCode: 'HEART_RATE',
|
||||
value: 72,
|
||||
unit: 'bpm',
|
||||
recordedAt: '2026-06-27T10:00:00Z',
|
||||
note: null,
|
||||
},
|
||||
{
|
||||
id: 'obs-2',
|
||||
batchId: 'b1',
|
||||
observationCode: 'TEMP_C',
|
||||
value: 37.2,
|
||||
unit: 'C',
|
||||
recordedAt: '2026-06-27T10:00:00Z',
|
||||
note: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return { wrapper, store }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('VerificationForm', () => {
|
||||
it('renders verification header', () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Verification Review')
|
||||
expect(wrapper.text()).toContain('Pending Verification')
|
||||
})
|
||||
|
||||
it('renders patient fields with checkboxes after draft loads', async () => {
|
||||
const { wrapper } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Patient Demographics')
|
||||
expect(wrapper.text()).toContain('Jane Doe')
|
||||
expect(wrapper.text()).toContain('1990-05-15')
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders encounter fields after draft loads', async () => {
|
||||
const { wrapper } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Encounter Context')
|
||||
expect(wrapper.text()).toContain('ICU')
|
||||
expect(wrapper.text()).toContain('3A-12')
|
||||
expect(wrapper.text()).toContain('Chest pain')
|
||||
})
|
||||
|
||||
it('shows rejection reason banner when present', () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: {
|
||||
batch: makeBatch({ rejectionReason: 'Temperature seems incorrect' }),
|
||||
batchId: 'b1',
|
||||
},
|
||||
})
|
||||
expect(wrapper.text()).toContain('Previous Rejection Reason')
|
||||
expect(wrapper.text()).toContain('Temperature seems incorrect')
|
||||
})
|
||||
|
||||
it('does not show rejection banner when no reason', () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch({ rejectionReason: null }), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).not.toContain('Previous Rejection Reason')
|
||||
})
|
||||
|
||||
describe('field check progress', () => {
|
||||
it('shows 0/N checked initially', async () => {
|
||||
const { wrapper } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Fields verified:')
|
||||
expect(wrapper.text()).toMatch(/0\s*\/\s*\d+/)
|
||||
})
|
||||
|
||||
it('updates count when checkboxes are toggled', async () => {
|
||||
const { wrapper } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
await checkboxes[0].setValue(true)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toMatch(/1\s*\/\s*\d+/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('approve button', () => {
|
||||
it('is disabled when not all fields are checked', async () => {
|
||||
const { wrapper } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
|
||||
expect(approveBtn!.element.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('is enabled when all fields are checked', async () => {
|
||||
const { wrapper } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
for (const cb of checkboxes) {
|
||||
await cb.setValue(true)
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
|
||||
expect(approveBtn!.element.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('calls verifyBatch with all field checks on approve', async () => {
|
||||
const { wrapper, store } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
store.verifyBatch = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
for (const cb of checkboxes) {
|
||||
await cb.setValue(true)
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
|
||||
await approveBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(store.verifyBatch).toHaveBeenCalledWith(
|
||||
'b1',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ passed: true }),
|
||||
]),
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reject flow', () => {
|
||||
it('shows reject dialog when reject button is clicked', async () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||
await rejectBtn!.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Reject Batch')
|
||||
expect(wrapper.text()).toContain('Confirm Rejection')
|
||||
})
|
||||
|
||||
it('disables confirm button when reason is empty', async () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||
await rejectBtn!.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection')
|
||||
expect(confirmBtn!.element.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('enables confirm button when reason is entered', async () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||
await rejectBtn!.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Temperature value appears incorrect')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection')
|
||||
expect(confirmBtn!.element.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('calls rejectBatch on confirm', async () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.rejectBatch = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||
await rejectBtn!.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Value incorrect')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection')
|
||||
await confirmBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(store.rejectBatch).toHaveBeenCalledWith('b1', 'Value incorrect')
|
||||
})
|
||||
|
||||
it('closes reject dialog on cancel', async () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||
await rejectBtn!.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Reject Batch')
|
||||
|
||||
const cancelBtn = wrapper.findAll('button').find((b) => b.text() === 'Cancel')
|
||||
await cancelBtn!.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Reject Batch')
|
||||
})
|
||||
})
|
||||
|
||||
describe('observations display', () => {
|
||||
it('shows observation count in legend', async () => {
|
||||
const { wrapper } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Observations (2)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('allergy and medication verification', () => {
|
||||
it('shows allergy fields for ALLERGY_UPDATE batch', async () => {
|
||||
const { wrapper } = mountWithDraft({ batchType: 'ALLERGY_UPDATE' })
|
||||
const store = useBatchStore()
|
||||
store.currentDraft!.patient!.allergiesJson = JSON.stringify(['Penicillin', 'Latex'])
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Re-trigger the watcher by resetting draft
|
||||
const draft = { ...store.currentDraft! }
|
||||
store.currentDraft = null
|
||||
await wrapper.vm.$nextTick()
|
||||
store.currentDraft = draft
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Allergies')
|
||||
})
|
||||
|
||||
it('shows NKA for noKnownAllergies', async () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch({ batchType: 'ALLERGY_UPDATE' }), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.currentDraft = {
|
||||
patient: {
|
||||
id: 'dp1',
|
||||
batchId: 'b1',
|
||||
fullName: 'Jane',
|
||||
dateOfBirth: '1990-01-01',
|
||||
sex: 'female',
|
||||
bloodType: null,
|
||||
emergencyContact: null,
|
||||
allergiesJson: null,
|
||||
noKnownAllergies: true,
|
||||
medicationsJson: null,
|
||||
noActiveMedications: false,
|
||||
},
|
||||
encounter: {
|
||||
id: 'de1',
|
||||
batchId: 'b1',
|
||||
admissionDate: null,
|
||||
department: null,
|
||||
roomBed: null,
|
||||
admissionReason: null,
|
||||
dischargeDiagnosis: null,
|
||||
status: null,
|
||||
},
|
||||
observations: [],
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('No Known Allergies')
|
||||
expect(wrapper.text()).toContain('Yes (NKA)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('shows error message on approve failure', async () => {
|
||||
const { wrapper, store } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
store.verifyBatch = vi.fn().mockRejectedValue(new Error('Server error'))
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
for (const cb of checkboxes) {
|
||||
await cb.setValue(true)
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
|
||||
await approveBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Server error')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router'
|
||||
import { useAuthStore, getDefaultRouteForRole } from '@/stores/auth'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}))
|
||||
|
||||
function buildRoute(path: string, meta: Record<string, unknown> = {}): RouteLocationNormalized {
|
||||
return {
|
||||
path,
|
||||
meta,
|
||||
name: undefined,
|
||||
params: {},
|
||||
query: {},
|
||||
hash: '',
|
||||
fullPath: path,
|
||||
matched: [],
|
||||
redirectedFrom: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function setupGuard() {
|
||||
const nextCalls: (string | undefined)[] = []
|
||||
const auth = useAuthStore()
|
||||
|
||||
function runGuard(to: RouteLocationNormalized, from?: RouteLocationNormalized) {
|
||||
const _from = from ?? buildRoute('/')
|
||||
const next = vi.fn((dest?: string) => {
|
||||
nextCalls.push(dest)
|
||||
})
|
||||
|
||||
if (to.path === '/login' && auth.isAuthenticated) {
|
||||
next(getDefaultRouteForRole(auth.userRole))
|
||||
return { next, nextCalls }
|
||||
}
|
||||
|
||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||
next('/login')
|
||||
return { next, nextCalls }
|
||||
}
|
||||
|
||||
if (to.meta.roles && Array.isArray(to.meta.roles)) {
|
||||
const allowedRoles = to.meta.roles as string[]
|
||||
if (!allowedRoles.includes(auth.userRole)) {
|
||||
const fallback = getDefaultRouteForRole(auth.userRole)
|
||||
if (fallback !== '/login' && fallback !== to.path) {
|
||||
next(fallback)
|
||||
return { next, nextCalls }
|
||||
}
|
||||
next('/login')
|
||||
return { next, nextCalls }
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
return { next, nextCalls }
|
||||
}
|
||||
|
||||
return { auth, runGuard }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('router navigation guard', () => {
|
||||
describe('unauthenticated users', () => {
|
||||
it('allows access to /login', () => {
|
||||
const { runGuard } = setupGuard()
|
||||
const { next } = runGuard(buildRoute('/login', { requiresAuth: false }))
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('redirects to /login for protected routes', () => {
|
||||
const { runGuard } = setupGuard()
|
||||
const { next } = runGuard(buildRoute('/entry', { requiresAuth: true, roles: ['DATA_ENTRY_CLERK'] }))
|
||||
expect(next).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
|
||||
it('redirects to /login for dashboard', () => {
|
||||
const { runGuard } = setupGuard()
|
||||
const { next } = runGuard(buildRoute('/dashboard', { requiresAuth: true, roles: ['ADMINISTRATOR'] }))
|
||||
expect(next).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated users', () => {
|
||||
function authenticatedGuard(role: string) {
|
||||
const { auth, runGuard } = setupGuard()
|
||||
auth.token = 'valid-token'
|
||||
auth.user = { id: 'u1', username: 'test', fullName: 'Test User', role }
|
||||
return { auth, runGuard }
|
||||
}
|
||||
|
||||
it('redirects authenticated user away from /login to default route', () => {
|
||||
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||
const { next } = runGuard(buildRoute('/login', { requiresAuth: false }))
|
||||
expect(next).toHaveBeenCalledWith('/entry')
|
||||
})
|
||||
|
||||
it('redirects admin from /login to /dashboard', () => {
|
||||
const { runGuard } = authenticatedGuard('ADMINISTRATOR')
|
||||
const { next } = runGuard(buildRoute('/login', { requiresAuth: false }))
|
||||
expect(next).toHaveBeenCalledWith('/dashboard')
|
||||
})
|
||||
|
||||
it('allows access to routes matching user role', () => {
|
||||
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||
const { next } = runGuard(buildRoute('/entry', { requiresAuth: true, roles: ['DATA_ENTRY_CLERK', 'ADMINISTRATOR'] }))
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('allows admin access to any role-restricted route', () => {
|
||||
const { runGuard } = authenticatedGuard('ADMINISTRATOR')
|
||||
const routeMetas = [
|
||||
{ requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] },
|
||||
{ requiresAuth: true, roles: ['DATA_ENTRY_CLERK', 'ADMINISTRATOR'] },
|
||||
{ requiresAuth: true, roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'] },
|
||||
{ requiresAuth: true, roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'] },
|
||||
{ requiresAuth: true, roles: ['CLINICIAN', 'ADMINISTRATOR'] },
|
||||
{ requiresAuth: true, roles: ['ADMINISTRATOR'] },
|
||||
]
|
||||
for (const meta of routeMetas) {
|
||||
const { next } = runGuard(buildRoute('/test', meta))
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
}
|
||||
})
|
||||
|
||||
it('allows INTAKE_CLERK access to cover sheets route', () => {
|
||||
const { runGuard } = authenticatedGuard('INTAKE_CLERK')
|
||||
const { next } = runGuard(buildRoute('/cover-sheets', {
|
||||
requiresAuth: true,
|
||||
roles: ['INTAKE_CLERK', 'ADMINISTRATOR'],
|
||||
}))
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('redirects DATA_ENTRY_CLERK from cover sheets to /entry', () => {
|
||||
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||
const { next } = runGuard(buildRoute('/cover-sheets', {
|
||||
requiresAuth: true,
|
||||
roles: ['INTAKE_CLERK', 'ADMINISTRATOR'],
|
||||
}))
|
||||
expect(next).toHaveBeenCalledWith('/entry')
|
||||
})
|
||||
|
||||
it('redirects DATA_ENTRY_CLERK from /intake to /entry', () => {
|
||||
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||
const { next } = runGuard(buildRoute('/intake', { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] }))
|
||||
expect(next).toHaveBeenCalledWith('/entry')
|
||||
})
|
||||
|
||||
it('redirects INTAKE_CLERK from /verification to /intake', () => {
|
||||
const { runGuard } = authenticatedGuard('INTAKE_CLERK')
|
||||
const { next } = runGuard(buildRoute('/verification', {
|
||||
requiresAuth: true,
|
||||
roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||
}))
|
||||
expect(next).toHaveBeenCalledWith('/intake')
|
||||
})
|
||||
|
||||
it('redirects CLINICIAN from /dashboard to /live-capture', () => {
|
||||
const { runGuard } = authenticatedGuard('CLINICIAN')
|
||||
const { next } = runGuard(buildRoute('/dashboard', { requiresAuth: true, roles: ['ADMINISTRATOR'] }))
|
||||
expect(next).toHaveBeenCalledWith('/live-capture')
|
||||
})
|
||||
|
||||
it('allows access to routes with no role restriction', () => {
|
||||
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||
const { next } = runGuard(buildRoute('/patients', { requiresAuth: true }))
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('VERIFIER can access verification routes', () => {
|
||||
const { runGuard } = authenticatedGuard('VERIFIER')
|
||||
const { next } = runGuard(buildRoute('/verification', {
|
||||
requiresAuth: true,
|
||||
roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||
}))
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('CLINICAL_APPROVER can access both verification and approval routes', () => {
|
||||
const { runGuard } = authenticatedGuard('CLINICAL_APPROVER')
|
||||
|
||||
const { next: verifyNext } = runGuard(buildRoute('/verification', {
|
||||
requiresAuth: true,
|
||||
roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||
}))
|
||||
expect(verifyNext).toHaveBeenCalledWith()
|
||||
|
||||
const { next: approvalNext } = runGuard(buildRoute('/approval', {
|
||||
requiresAuth: true,
|
||||
roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||
}))
|
||||
expect(approvalNext).toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
const localStorageData: Record<string, string> = {}
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: {
|
||||
getItem: vi.fn((key: string) => localStorageData[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
localStorageData[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete localStorageData[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
Object.keys(localStorageData).forEach((k) => delete localStorageData[k])
|
||||
}),
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
@@ -0,0 +1,312 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAuthStore, getDefaultRouteForRole } from '@/stores/auth'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/router', () => ({
|
||||
default: { push: vi.fn() },
|
||||
}))
|
||||
|
||||
import { post, get } from '@/api/client'
|
||||
import router from '@/router'
|
||||
|
||||
const mockedPost = vi.mocked(post)
|
||||
const mockedGet = vi.mocked(get)
|
||||
const mockedRouterPush = vi.mocked(router.push)
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('getDefaultRouteForRole', () => {
|
||||
it.each([
|
||||
['INTAKE_CLERK', '/intake'],
|
||||
['DATA_ENTRY_CLERK', '/entry'],
|
||||
['VERIFIER', '/verification'],
|
||||
['CLINICAL_APPROVER', '/approval'],
|
||||
['CLINICIAN', '/live-capture'],
|
||||
['ADMINISTRATOR', '/dashboard'],
|
||||
['UNKNOWN_ROLE', '/login'],
|
||||
['', '/login'],
|
||||
])('returns %s for role %s', (role, expected) => {
|
||||
expect(getDefaultRouteForRole(role)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAuthStore', () => {
|
||||
describe('initial state', () => {
|
||||
it('starts unauthenticated when localStorage is empty', () => {
|
||||
const store = useAuthStore()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.user).toBeNull()
|
||||
expect(store.userRole).toBe('')
|
||||
expect(store.userId).toBe('')
|
||||
expect(store.userFullName).toBe('')
|
||||
})
|
||||
|
||||
it('hydrates from localStorage on creation', () => {
|
||||
localStorage.setItem('vigilcare_token', 'stored-token')
|
||||
localStorage.setItem('vigilcare_refresh_token', 'stored-refresh')
|
||||
localStorage.setItem(
|
||||
'vigilcare_user',
|
||||
JSON.stringify({ id: 'u1', username: 'clerk1', fullName: 'Clerk One', role: 'DATA_ENTRY_CLERK' }),
|
||||
)
|
||||
|
||||
setActivePinia(createPinia())
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(store.userRole).toBe('DATA_ENTRY_CLERK')
|
||||
expect(store.userId).toBe('u1')
|
||||
expect(store.userFullName).toBe('Clerk One')
|
||||
})
|
||||
})
|
||||
|
||||
describe('role-based permissions', () => {
|
||||
function storeWithRole(role: string) {
|
||||
const store = useAuthStore()
|
||||
store.user = { id: 'u1', username: 'test', fullName: 'Test', role }
|
||||
store.token = 'tok'
|
||||
return store
|
||||
}
|
||||
|
||||
it('INTAKE_CLERK can intake only', () => {
|
||||
const store = storeWithRole('INTAKE_CLERK')
|
||||
expect(store.canIntake).toBe(true)
|
||||
expect(store.canEntry).toBe(false)
|
||||
expect(store.canVerify).toBe(false)
|
||||
expect(store.canApprove).toBe(false)
|
||||
expect(store.canLiveCapture).toBe(false)
|
||||
expect(store.canSupervise).toBe(false)
|
||||
})
|
||||
|
||||
it('DATA_ENTRY_CLERK can entry only', () => {
|
||||
const store = storeWithRole('DATA_ENTRY_CLERK')
|
||||
expect(store.canEntry).toBe(true)
|
||||
expect(store.canIntake).toBe(false)
|
||||
expect(store.canVerify).toBe(false)
|
||||
expect(store.canApprove).toBe(false)
|
||||
})
|
||||
|
||||
it('VERIFIER can verify only', () => {
|
||||
const store = storeWithRole('VERIFIER')
|
||||
expect(store.canVerify).toBe(true)
|
||||
expect(store.canIntake).toBe(false)
|
||||
expect(store.canEntry).toBe(false)
|
||||
expect(store.canApprove).toBe(false)
|
||||
})
|
||||
|
||||
it('CLINICAL_APPROVER can verify and approve', () => {
|
||||
const store = storeWithRole('CLINICAL_APPROVER')
|
||||
expect(store.canVerify).toBe(true)
|
||||
expect(store.canApprove).toBe(true)
|
||||
expect(store.canIntake).toBe(false)
|
||||
expect(store.canEntry).toBe(false)
|
||||
})
|
||||
|
||||
it('CLINICIAN can live capture only', () => {
|
||||
const store = storeWithRole('CLINICIAN')
|
||||
expect(store.canLiveCapture).toBe(true)
|
||||
expect(store.canIntake).toBe(false)
|
||||
expect(store.canEntry).toBe(false)
|
||||
expect(store.canVerify).toBe(false)
|
||||
expect(store.canSupervise).toBe(false)
|
||||
})
|
||||
|
||||
it('ADMINISTRATOR has all permissions', () => {
|
||||
const store = storeWithRole('ADMINISTRATOR')
|
||||
expect(store.canIntake).toBe(true)
|
||||
expect(store.canEntry).toBe(true)
|
||||
expect(store.canVerify).toBe(true)
|
||||
expect(store.canApprove).toBe(true)
|
||||
expect(store.canLiveCapture).toBe(true)
|
||||
expect(store.canSupervise).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('sets token, user, and localStorage on successful login', async () => {
|
||||
mockedPost.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: {
|
||||
token: 'jwt-tok',
|
||||
refreshToken: 'ref-tok',
|
||||
userId: 'u-123',
|
||||
username: 'entry1',
|
||||
fullName: 'Entry Clerk',
|
||||
role: 'DATA_ENTRY_CLERK',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useAuthStore()
|
||||
await store.login({ username: 'entry1', password: 'password' })
|
||||
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(store.token).toBe('jwt-tok')
|
||||
expect(store.refreshToken).toBe('ref-tok')
|
||||
expect(store.user).toEqual({
|
||||
id: 'u-123',
|
||||
username: 'entry1',
|
||||
fullName: 'Entry Clerk',
|
||||
role: 'DATA_ENTRY_CLERK',
|
||||
})
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('vigilcare_token', 'jwt-tok')
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('vigilcare_refresh_token', 'ref-tok')
|
||||
expect(mockedRouterPush).toHaveBeenCalledWith('/entry')
|
||||
})
|
||||
|
||||
it('navigates to role-specific default route', async () => {
|
||||
mockedPost.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: {
|
||||
token: 't',
|
||||
refreshToken: 'r',
|
||||
userId: 'u1',
|
||||
username: 'admin',
|
||||
fullName: 'Admin',
|
||||
role: 'ADMINISTRATOR',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useAuthStore()
|
||||
await store.login({ username: 'admin', password: 'password' })
|
||||
|
||||
expect(mockedRouterPush).toHaveBeenCalledWith('/dashboard')
|
||||
})
|
||||
|
||||
it('throws on failed login', async () => {
|
||||
mockedPost.mockResolvedValueOnce({
|
||||
success: false,
|
||||
statusCode: 401,
|
||||
data: null,
|
||||
error: { message: 'Invalid credentials', code: 'AUTH_FAILED' },
|
||||
})
|
||||
|
||||
const store = useAuthStore()
|
||||
await expect(store.login({ username: 'bad', password: 'wrong' })).rejects.toThrow(
|
||||
'Invalid credentials',
|
||||
)
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('throws generic message when error has no message', async () => {
|
||||
mockedPost.mockResolvedValueOnce({
|
||||
success: false,
|
||||
statusCode: 500,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useAuthStore()
|
||||
await expect(store.login({ username: 'x', password: 'y' })).rejects.toThrow('Login failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchCurrentUser', () => {
|
||||
it('updates user from /auth/me response', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: { id: 'u-99', username: 'verifier1', fullName: 'Verifier One', role: 'VERIFIER' },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useAuthStore()
|
||||
await store.fetchCurrentUser()
|
||||
|
||||
expect(store.user).toEqual({
|
||||
id: 'u-99',
|
||||
username: 'verifier1',
|
||||
fullName: 'Verifier One',
|
||||
role: 'VERIFIER',
|
||||
})
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
'vigilcare_user',
|
||||
JSON.stringify(store.user),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not update user on failed response', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: false,
|
||||
statusCode: 401,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useAuthStore()
|
||||
store.user = { id: 'old', username: 'old', fullName: 'Old', role: 'VERIFIER' }
|
||||
await store.fetchCurrentUser()
|
||||
|
||||
expect(store.user?.id).toBe('old')
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('clears state, localStorage, and navigates to /login', async () => {
|
||||
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null })
|
||||
|
||||
const store = useAuthStore()
|
||||
store.token = 'tok'
|
||||
store.refreshToken = 'ref'
|
||||
store.user = { id: 'u1', username: 'x', fullName: 'X', role: 'VERIFIER' }
|
||||
|
||||
await store.logout()
|
||||
|
||||
expect(store.token).toBeNull()
|
||||
expect(store.refreshToken).toBeNull()
|
||||
expect(store.user).toBeNull()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('vigilcare_token')
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('vigilcare_refresh_token')
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('vigilcare_user')
|
||||
expect(mockedRouterPush).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
|
||||
it('posts refresh token to server on logout', async () => {
|
||||
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null })
|
||||
|
||||
const store = useAuthStore()
|
||||
store.refreshToken = 'my-refresh-tok'
|
||||
|
||||
await store.logout()
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledWith('auth/logout', { refreshToken: 'my-refresh-tok' })
|
||||
})
|
||||
|
||||
it('still clears local session if server revoke fails', async () => {
|
||||
mockedPost.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const store = useAuthStore()
|
||||
store.token = 'tok'
|
||||
store.refreshToken = 'ref'
|
||||
|
||||
await store.logout()
|
||||
|
||||
expect(store.token).toBeNull()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(mockedRouterPush).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
|
||||
it('skips server call when no refresh token', async () => {
|
||||
const store = useAuthStore()
|
||||
store.token = 'tok'
|
||||
store.refreshToken = null
|
||||
|
||||
await store.logout()
|
||||
|
||||
expect(mockedPost).not.toHaveBeenCalled()
|
||||
expect(store.token).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,405 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useBatchStore } from '@/stores/batches'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
del: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
}))
|
||||
|
||||
import { get, post, put, del, patch, uploadFile } from '@/api/client'
|
||||
|
||||
const mockedGet = vi.mocked(get)
|
||||
const mockedPost = vi.mocked(post)
|
||||
const mockedPut = vi.mocked(put)
|
||||
const mockedDel = vi.mocked(del)
|
||||
const mockedPatch = vi.mocked(patch)
|
||||
const mockedUploadFile = vi.mocked(uploadFile)
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useBatchStore', () => {
|
||||
describe('initial state', () => {
|
||||
it('has expected defaults', () => {
|
||||
const store = useBatchStore()
|
||||
expect(store.batches).toEqual([])
|
||||
expect(store.currentBatch).toBeNull()
|
||||
expect(store.currentDraft).toBeNull()
|
||||
expect(store.totalCount).toBe(0)
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
expect(store.documentUrl).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('listBatches', () => {
|
||||
it('populates batches and totalCount on success', async () => {
|
||||
const items = [
|
||||
{ id: 'b1', status: 'UPLOADED', batchType: 'VITALS', track: 'TRACK_A' },
|
||||
{ id: 'b2', status: 'IN_ENTRY', batchType: 'LABS', track: 'TRACK_A' },
|
||||
]
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: { items, totalCount: 42, page: 1, pageSize: 20, totalPages: 3 },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.listBatches({ status: 'UPLOADED' })
|
||||
|
||||
expect(store.batches).toEqual(items)
|
||||
expect(store.totalCount).toBe(42)
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('sets error on failure', async () => {
|
||||
mockedGet.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.listBatches({})
|
||||
|
||||
expect(store.error).toBe('Network error')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('sets generic error for non-Error exceptions', async () => {
|
||||
mockedGet.mockRejectedValueOnce('string error')
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.listBatches({})
|
||||
|
||||
expect(store.error).toBe('Failed to load batches')
|
||||
})
|
||||
|
||||
it('sets loading=true during request', async () => {
|
||||
let resolvePromise: (v: unknown) => void
|
||||
mockedGet.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const store = useBatchStore()
|
||||
const promise = store.listBatches({})
|
||||
|
||||
expect(store.loading).toBe(true)
|
||||
|
||||
resolvePromise!({ success: true, statusCode: 200, data: { items: [], totalCount: 0 }, error: null })
|
||||
await promise
|
||||
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBatch', () => {
|
||||
it('sets currentBatch on success', async () => {
|
||||
const batch = { id: 'b1', status: 'UPLOADED', batchType: 'VITALS', documentUrl: 'https://example.com/doc' }
|
||||
mockedGet.mockResolvedValueOnce({ success: true, statusCode: 200, data: batch, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.getBatch('b1')
|
||||
|
||||
expect(store.currentBatch).toEqual(batch)
|
||||
expect(store.documentUrl).toBe('https://example.com/doc')
|
||||
})
|
||||
|
||||
it('sets error on failure', async () => {
|
||||
mockedGet.mockRejectedValueOnce(new Error('Not found'))
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.getBatch('bad-id')
|
||||
|
||||
expect(store.error).toBe('Not found')
|
||||
expect(store.currentBatch).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadBatch', () => {
|
||||
it('returns batch data on success', async () => {
|
||||
const batch = { id: 'new-batch', status: 'UPLOADED' }
|
||||
mockedUploadFile.mockResolvedValueOnce({ success: true, statusCode: 201, data: batch, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
const file = new File(['content'], 'scan.pdf', { type: 'application/pdf' })
|
||||
const result = await store.uploadBatch(file, 'VITALS', 'TRACK_A')
|
||||
|
||||
expect(result).toEqual(batch)
|
||||
expect(mockedUploadFile).toHaveBeenCalledWith('digitization-batches', file, {
|
||||
batchType: 'VITALS',
|
||||
track: 'TRACK_A',
|
||||
})
|
||||
})
|
||||
|
||||
it('passes optional patientId and supersedesBatchId', async () => {
|
||||
mockedUploadFile.mockResolvedValueOnce({ success: true, statusCode: 201, data: { id: 'b' }, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
const file = new File(['content'], 'scan.pdf')
|
||||
await store.uploadBatch(file, 'VITALS', 'TRACK_A', 'patient-1', 'old-batch')
|
||||
|
||||
expect(mockedUploadFile).toHaveBeenCalledWith('digitization-batches', file, {
|
||||
batchType: 'VITALS',
|
||||
track: 'TRACK_A',
|
||||
patientId: 'patient-1',
|
||||
supersedesBatchId: 'old-batch',
|
||||
})
|
||||
})
|
||||
|
||||
it('passes coverSheetCode when provided', async () => {
|
||||
mockedUploadFile.mockResolvedValueOnce({ success: true, statusCode: 201, data: { id: 'b' }, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
const file = new File(['content'], 'scan.pdf')
|
||||
await store.uploadBatch(
|
||||
file,
|
||||
'VITALS_SHEET',
|
||||
'BACKFILL',
|
||||
undefined,
|
||||
undefined,
|
||||
'VCR-CS-A3F7B2D1',
|
||||
)
|
||||
|
||||
expect(mockedUploadFile).toHaveBeenCalledWith('digitization-batches', file, {
|
||||
batchType: 'VITALS_SHEET',
|
||||
track: 'BACKFILL',
|
||||
coverSheetCode: 'VCR-CS-A3F7B2D1',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null on failure', async () => {
|
||||
mockedUploadFile.mockRejectedValueOnce(new Error('Upload failed'))
|
||||
|
||||
const store = useBatchStore()
|
||||
const file = new File([''], 'scan.pdf')
|
||||
const result = await store.uploadBatch(file, 'VITALS', 'TRACK_A')
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(store.error).toBe('Upload failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('assignBatch', () => {
|
||||
it('calls PATCH with entryClerkUserId', async () => {
|
||||
mockedPatch.mockResolvedValueOnce({ success: true, statusCode: 200, data: {}, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.assignBatch('b1', 'clerk-42')
|
||||
|
||||
expect(mockedPatch).toHaveBeenCalledWith('digitization-batches/b1/assign', {
|
||||
entryClerkUserId: 'clerk-42',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws on failure', async () => {
|
||||
mockedPatch.mockResolvedValueOnce({
|
||||
success: false,
|
||||
statusCode: 409,
|
||||
data: null,
|
||||
error: { message: 'Already assigned', code: 'CONFLICT' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
await expect(store.assignBatch('b1', 'clerk-42')).rejects.toThrow('Already assigned')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDraft', () => {
|
||||
it('sets currentDraft on success', async () => {
|
||||
const draft = {
|
||||
patient: { fullName: 'Jane', dateOfBirth: '1990-01-01' },
|
||||
encounter: { department: 'ICU' },
|
||||
observations: [{ id: 'obs-1', observationCode: 'HEART_RATE', value: 72 }],
|
||||
}
|
||||
mockedGet.mockResolvedValueOnce({ success: true, statusCode: 200, data: draft, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.getDraft('b1')
|
||||
|
||||
expect(store.currentDraft).toEqual(draft)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveDraftPatient', () => {
|
||||
it('calls PUT with patient data', async () => {
|
||||
mockedPut.mockResolvedValueOnce({ success: true, statusCode: 200, data: {}, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.saveDraftPatient('b1', { fullName: 'John Doe' })
|
||||
|
||||
expect(mockedPut).toHaveBeenCalledWith('digitization-batches/b1/draft/patient', {
|
||||
fullName: 'John Doe',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveDraftEncounter', () => {
|
||||
it('calls PUT with encounter data', async () => {
|
||||
mockedPut.mockResolvedValueOnce({ success: true, statusCode: 200, data: {}, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.saveDraftEncounter('b1', { department: 'ICU' })
|
||||
|
||||
expect(mockedPut).toHaveBeenCalledWith('digitization-batches/b1/draft/encounter', {
|
||||
department: 'ICU',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('addObservation', () => {
|
||||
it('posts observation and refreshes draft', async () => {
|
||||
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 201, data: {}, error: null })
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: { patient: null, encounter: null, observations: [{ id: 'obs-new' }] },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.addObservation('b1', { observationCode: 'HEART_RATE', value: 72, unit: 'bpm' })
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledWith('digitization-batches/b1/draft/observations', {
|
||||
observationCode: 'HEART_RATE',
|
||||
value: 72,
|
||||
unit: 'bpm',
|
||||
})
|
||||
expect(mockedGet).toHaveBeenCalledWith('digitization-batches/b1/draft')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateObservation', () => {
|
||||
it('calls PUT with observation data', async () => {
|
||||
mockedPut.mockResolvedValueOnce({ success: true, statusCode: 200, data: {}, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.updateObservation('b1', 'obs-1', { value: 80 })
|
||||
|
||||
expect(mockedPut).toHaveBeenCalledWith('digitization-batches/b1/draft/observations/obs-1', {
|
||||
value: 80,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteObservation', () => {
|
||||
it('calls DELETE and refreshes draft', async () => {
|
||||
mockedDel.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null })
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: { patient: null, encounter: null, observations: [] },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.deleteObservation('b1', 'obs-1')
|
||||
|
||||
expect(mockedDel).toHaveBeenCalledWith('digitization-batches/b1/draft/observations/obs-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitForVerification', () => {
|
||||
it('posts to submit endpoint', async () => {
|
||||
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.submitForVerification('b1')
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledWith('digitization-batches/b1/submit-for-verification')
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyBatch', () => {
|
||||
it('posts field checks and pass status', async () => {
|
||||
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null })
|
||||
|
||||
const checks = [
|
||||
{ fieldPath: 'patient.fullName', passed: true },
|
||||
{ fieldPath: 'patient.dob', passed: true },
|
||||
]
|
||||
const store = useBatchStore()
|
||||
await store.verifyBatch('b1', checks, true)
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledWith('digitization-batches/b1/verify', {
|
||||
fieldChecks: checks,
|
||||
passed: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('rejectBatch', () => {
|
||||
it('posts rejection reason', async () => {
|
||||
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.rejectBatch('b1', 'Temperature value appears incorrect')
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledWith('digitization-batches/b1/reject', {
|
||||
reason: 'Temperature value appears incorrect',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('approveBatch', () => {
|
||||
it('posts with idempotency key and enableRetroactiveAlerts', async () => {
|
||||
mockedPost.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: { mrn: 'MRN-001', encounterId: 'enc-1', observationIds: ['o1', 'o2'] },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
const result = await store.approveBatch('b1', true)
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledWith(
|
||||
'digitization-batches/b1/approve',
|
||||
{ enableRetroactiveAlerts: true },
|
||||
expect.objectContaining({ 'Idempotency-Key': expect.any(String) }),
|
||||
)
|
||||
expect(result.data).toEqual({
|
||||
mrn: 'MRN-001',
|
||||
encounterId: 'enc-1',
|
||||
observationIds: ['o1', 'o2'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPatientHistory', () => {
|
||||
it('returns history on success', async () => {
|
||||
const history = {
|
||||
patientId: 'p1',
|
||||
totalBatches: 3,
|
||||
promotedBatches: 2,
|
||||
supersededBatches: 1,
|
||||
pendingBatches: 0,
|
||||
entries: [],
|
||||
}
|
||||
mockedGet.mockResolvedValueOnce({ success: true, statusCode: 200, data: history, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
const result = await store.getPatientHistory('p1')
|
||||
|
||||
expect(result).toEqual(history)
|
||||
expect(mockedGet).toHaveBeenCalledWith('patients/p1/digitization-history')
|
||||
})
|
||||
|
||||
it('returns null and sets error on failure', async () => {
|
||||
mockedGet.mockRejectedValueOnce(new Error('Not found'))
|
||||
|
||||
const store = useBatchStore()
|
||||
const result = await store.getPatientHistory('bad')
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(store.error).toBe('Not found')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useLiveCaptureStore } from '@/stores/liveCapture'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
import { post } from '@/api/client'
|
||||
|
||||
const mockedPost = vi.mocked(post)
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useLiveCaptureStore', () => {
|
||||
describe('initial state', () => {
|
||||
it('has expected defaults', () => {
|
||||
const store = useLiveCaptureStore()
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
expect(store.lastResult).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('recordObservations', () => {
|
||||
const observations = [
|
||||
{ observationCode: 'HEART_RATE', value: 72, unit: 'bpm', recordedAt: '2026-06-27T10:00:00Z', note: '' },
|
||||
]
|
||||
|
||||
it('returns result on success', async () => {
|
||||
const responseData = {
|
||||
batchId: 'b1',
|
||||
encounterId: 'enc-1',
|
||||
observations: [
|
||||
{
|
||||
draftObservationId: 'do1',
|
||||
liveObservationId: 'lo1',
|
||||
observationCode: 'HEART_RATE',
|
||||
value: 72,
|
||||
unit: 'bpm',
|
||||
recordedAt: '2026-06-27T10:00:00Z',
|
||||
criticalAlert: null,
|
||||
},
|
||||
],
|
||||
criticalAlertCount: 0,
|
||||
promotedAt: '2026-06-27T10:00:01Z',
|
||||
}
|
||||
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: responseData, error: null })
|
||||
|
||||
const store = useLiveCaptureStore()
|
||||
const result = await store.recordObservations('enc-1', observations, true, 'password')
|
||||
|
||||
expect(result).toEqual(responseData)
|
||||
expect(store.lastResult).toEqual(responseData)
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('throws and sets error on failure response', async () => {
|
||||
mockedPost.mockResolvedValueOnce({
|
||||
success: false,
|
||||
statusCode: 400,
|
||||
data: null,
|
||||
error: { message: 'Attestation required', code: 'VALIDATION' },
|
||||
})
|
||||
|
||||
const store = useLiveCaptureStore()
|
||||
await expect(
|
||||
store.recordObservations('enc-1', observations, false, 'password'),
|
||||
).rejects.toThrow('Attestation required')
|
||||
|
||||
expect(store.error).toBe('Attestation required')
|
||||
expect(store.lastResult).toBeNull()
|
||||
})
|
||||
|
||||
it('throws on network error', async () => {
|
||||
mockedPost.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const store = useLiveCaptureStore()
|
||||
await expect(
|
||||
store.recordObservations('enc-1', observations, true, 'password'),
|
||||
).rejects.toThrow('Network error')
|
||||
|
||||
expect(store.error).toBe('Network error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('openEncounterWithVitals', () => {
|
||||
const observations = [
|
||||
{ observationCode: 'TEMP_C', value: 37.2, unit: 'C', recordedAt: '2026-06-27T10:00:00Z', note: '' },
|
||||
]
|
||||
|
||||
it('returns result on success', async () => {
|
||||
const responseData = {
|
||||
batchId: 'b2',
|
||||
encounterId: 'enc-new',
|
||||
observations: [],
|
||||
criticalAlertCount: 0,
|
||||
promotedAt: '2026-06-27T10:00:01Z',
|
||||
}
|
||||
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 201, data: responseData, error: null })
|
||||
|
||||
const store = useLiveCaptureStore()
|
||||
const result = await store.openEncounterWithVitals(
|
||||
'patient-1', 'ICU', '3A-12', 'Chest pain', observations, true, 'password',
|
||||
)
|
||||
|
||||
expect(result).toEqual(responseData)
|
||||
expect(mockedPost).toHaveBeenCalledWith('live-capture/encounters', {
|
||||
patientId: 'patient-1',
|
||||
department: 'ICU',
|
||||
roomBed: '3A-12',
|
||||
admissionReason: 'Chest pain',
|
||||
observations: [
|
||||
{ observationCode: 'TEMP_C', value: 37.2, unit: 'C', recordedAt: '2026-06-27T10:00:00Z', note: null },
|
||||
],
|
||||
clinicianAttestation: true,
|
||||
passwordConfirm: 'password',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends null for empty roomBed', async () => {
|
||||
mockedPost.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 201,
|
||||
data: { batchId: 'b', encounterId: 'e', observations: [], criticalAlertCount: 0, promotedAt: '' },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useLiveCaptureStore()
|
||||
await store.openEncounterWithVitals(
|
||||
'p1', 'ED', '', 'Fall', observations, true, 'pass',
|
||||
)
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledWith(
|
||||
'live-capture/encounters',
|
||||
expect.objectContaining({ roomBed: null }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset', () => {
|
||||
it('clears lastResult and error', () => {
|
||||
const store = useLiveCaptureStore()
|
||||
store.error = 'some error'
|
||||
store.lastResult = { batchId: 'b', encounterId: 'e', observations: [], criticalAlertCount: 0, promotedAt: '' }
|
||||
|
||||
store.reset()
|
||||
|
||||
expect(store.lastResult).toBeNull()
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import CoverSheetView from '@/views/CoverSheetView.vue'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
postBlob: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/AppHeader.vue', () => ({
|
||||
default: { template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/PatientSearch.vue', () => ({
|
||||
default: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<input data-testid="patient-search" />',
|
||||
},
|
||||
}))
|
||||
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
const mockedGet = vi.mocked(get)
|
||||
const mockedPost = vi.mocked(post)
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockedGet.mockImplementation(async (url: string) => {
|
||||
if (url === 'users') {
|
||||
return {
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: [{ id: 'clerk-1', username: 'entry1', fullName: 'Entry Clerk', role: 'DATA_ENTRY_CLERK' }],
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
if (url === 'cover-sheets') {
|
||||
return {
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: [
|
||||
{
|
||||
id: 'cs-1',
|
||||
code: 'VCR-CS-AABBCCDD',
|
||||
batchType: 'VITALS_SHEET',
|
||||
track: 'BACKFILL',
|
||||
patientId: null,
|
||||
patientName: null,
|
||||
patientMrn: null,
|
||||
assignToUserId: null,
|
||||
assignToUserName: null,
|
||||
isUsed: false,
|
||||
batchId: null,
|
||||
createdAt: '2026-06-27T12:00:00Z',
|
||||
usedAt: null,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
return { success: true, statusCode: 200, data: [], error: null }
|
||||
})
|
||||
})
|
||||
|
||||
describe('CoverSheetView', () => {
|
||||
it('renders generate form and cover sheet list', async () => {
|
||||
const wrapper = mount(CoverSheetView)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Generate Cover Sheets')
|
||||
expect(wrapper.text()).toContain('Cover Sheet List')
|
||||
expect(wrapper.text()).toContain('VCR-CS-AABBCCDD')
|
||||
expect(wrapper.find('select').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('loads entry clerks and cover sheets on mount', async () => {
|
||||
mount(CoverSheetView)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockedGet).toHaveBeenCalledWith('users', { role: 'DATA_ENTRY_CLERK' })
|
||||
expect(mockedGet).toHaveBeenCalledWith('cover-sheets', expect.objectContaining({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}))
|
||||
})
|
||||
|
||||
it('submits generate request with form values', async () => {
|
||||
mockedPost.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 201,
|
||||
data: [{ id: 'new-cs-1', code: 'VCR-CS-NEW12345' }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(CoverSheetView)
|
||||
await flushPromises()
|
||||
|
||||
const batchTypeSelect = wrapper.findAll('select')[0]
|
||||
await batchTypeSelect.setValue('VITALS_SHEET')
|
||||
|
||||
const countInput = wrapper.find('input[type="number"]')
|
||||
await countInput.setValue(5)
|
||||
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledWith('cover-sheets/generate', {
|
||||
count: 5,
|
||||
batchType: 'VITALS_SHEET',
|
||||
track: 'BACKFILL',
|
||||
})
|
||||
})
|
||||
|
||||
it('shows Print Cover Sheets after successful generation', async () => {
|
||||
mockedPost.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 201,
|
||||
data: [
|
||||
{ id: 'new-cs-1', code: 'VCR-CS-NEW12345' },
|
||||
{ id: 'new-cs-2', code: 'VCR-CS-NEW67890' },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(CoverSheetView)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('select')[0].setValue('LAB_RESULTS')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Print Cover Sheets')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import IntakeView from '@/views/IntakeView.vue'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
get: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/AppHeader.vue', () => ({
|
||||
default: { template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/PatientSearch.vue', () => ({
|
||||
default: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<input data-testid="patient-search" />',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/BatchList.vue', () => ({
|
||||
default: {
|
||||
props: ['batches', 'loading', 'showAssign'],
|
||||
emits: ['assign', 'select'],
|
||||
template: '<div data-testid="batch-list" />',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/AssignClerkDialog.vue', () => ({
|
||||
default: {
|
||||
props: ['show', 'batchId'],
|
||||
emits: ['close', 'assigned'],
|
||||
template: '<div />',
|
||||
},
|
||||
}))
|
||||
|
||||
const uploadBatchMock = vi.fn()
|
||||
|
||||
vi.mock('@/stores/batches', () => ({
|
||||
useBatchStore: () => ({
|
||||
batches: [],
|
||||
loading: false,
|
||||
listBatches: vi.fn(),
|
||||
uploadBatch: uploadBatchMock,
|
||||
assignBatch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
}))
|
||||
|
||||
import { get } from '@/api/client'
|
||||
|
||||
const mockedGet = vi.mocked(get)
|
||||
|
||||
const coverSheet = {
|
||||
id: 'cs-1',
|
||||
code: 'VCR-CS-A3F7B2D1',
|
||||
batchType: 'VITALS_SHEET',
|
||||
track: 'BACKFILL',
|
||||
patientId: 'patient-1',
|
||||
patientName: 'Maria Garcia',
|
||||
patientMrn: 'MRN-001',
|
||||
assignToUserId: null,
|
||||
assignToUserName: null,
|
||||
isUsed: false,
|
||||
batchId: null,
|
||||
createdAt: '2026-06-27T12:00:00Z',
|
||||
usedAt: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
uploadBatchMock.mockResolvedValue({ id: 'batch-1', status: 'UPLOADED' })
|
||||
})
|
||||
|
||||
describe('IntakeView cover sheet upload', () => {
|
||||
it('renders barcode input with autofocus and lookup button', () => {
|
||||
const wrapper = mount(IntakeView)
|
||||
const input = wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]')
|
||||
|
||||
expect(wrapper.text()).toContain('Quick Upload with Cover Sheet')
|
||||
expect(input.exists()).toBe(true)
|
||||
expect(input.attributes('autofocus')).toBeDefined()
|
||||
expect(wrapper.find('button.btn-secondary').text()).toBe('Lookup')
|
||||
})
|
||||
|
||||
it('looks up cover sheet on Enter and auto-fills form fields', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: coverSheet,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(IntakeView)
|
||||
const input = wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]')
|
||||
|
||||
await input.setValue('VCR-CS-A3F7B2D1')
|
||||
await input.trigger('keydown.enter')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockedGet).toHaveBeenCalledWith('cover-sheets/lookup/VCR-CS-A3F7B2D1')
|
||||
expect(wrapper.text()).toContain('Cover Sheet Found')
|
||||
expect(wrapper.text()).toContain('Maria Garcia')
|
||||
expect((wrapper.findAll('select')[0].element as HTMLSelectElement).value).toBe('VITALS_SHEET')
|
||||
expect((wrapper.findAll('select')[1].element as HTMLSelectElement).value).toBe('BACKFILL')
|
||||
})
|
||||
|
||||
it('uploads with coverSheetCode when cover sheet is resolved', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: coverSheet,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(IntakeView)
|
||||
|
||||
await wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]').setValue('VCR-CS-A3F7B2D1')
|
||||
await wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]').trigger('keydown.enter')
|
||||
await flushPromises()
|
||||
|
||||
const file = new File(['pdf'], 'scan.pdf', { type: 'application/pdf' })
|
||||
const fileInput = wrapper.find('input[type="file"]')
|
||||
Object.defineProperty(fileInput.element, 'files', { value: [file] })
|
||||
await fileInput.trigger('change')
|
||||
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(uploadBatchMock).toHaveBeenCalledWith(
|
||||
file,
|
||||
'VITALS_SHEET',
|
||||
'BACKFILL',
|
||||
'patient-1',
|
||||
undefined,
|
||||
'VCR-CS-A3F7B2D1',
|
||||
)
|
||||
expect(wrapper.text()).toContain('Upload with Cover Sheet')
|
||||
})
|
||||
|
||||
it('keeps manual upload available without a cover sheet', async () => {
|
||||
const wrapper = mount(IntakeView)
|
||||
|
||||
expect(wrapper.text()).toContain('New Batch')
|
||||
expect(wrapper.text()).toContain('Upload and Create Batch')
|
||||
|
||||
const file = new File(['pdf'], 'scan.pdf', { type: 'application/pdf' })
|
||||
const fileInput = wrapper.find('input[type="file"]')
|
||||
Object.defineProperty(fileInput.element, 'files', { value: [file] })
|
||||
await fileInput.trigger('change')
|
||||
await wrapper.findAll('select')[0].setValue('LAB_RESULTS')
|
||||
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(uploadBatchMock).toHaveBeenCalledWith(
|
||||
file,
|
||||
'LAB_RESULTS',
|
||||
'BACKFILL',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it('shows lookup error for unknown cover sheet', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: false,
|
||||
statusCode: 404,
|
||||
data: null,
|
||||
error: { message: 'Cover sheet not found.', code: 'COVER_SHEET_NOT_FOUND' },
|
||||
})
|
||||
|
||||
const wrapper = mount(IntakeView)
|
||||
await wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]').setValue('VCR-CS-DEADBEEF')
|
||||
await wrapper.find('button.btn-secondary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Cover sheet not found.')
|
||||
})
|
||||
})
|
||||
@@ -174,4 +174,13 @@ export async function uploadFile<T>(
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** POST JSON and receive a binary response (e.g. cover sheet PDF). */
|
||||
export async function postBlob(url: string, data?: unknown): Promise<Blob> {
|
||||
const response = await apiClient.post(url, data, {
|
||||
responseType: 'blob',
|
||||
timeout: 60000,
|
||||
})
|
||||
return response.data as Blob
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
@@ -8,6 +8,11 @@
|
||||
hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply bg-white text-primary-700 border border-primary-300 px-4 py-2 rounded-md
|
||||
hover:bg-primary-50 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply bg-clinical-danger text-white px-4 py-2 rounded-md
|
||||
hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<h1 class="text-lg font-semibold">{{ title }}</h1>
|
||||
<nav class="flex gap-3 ml-4">
|
||||
<router-link v-if="auth.canIntake" to="/intake" class="nav-link">Intake</router-link>
|
||||
<router-link v-if="auth.canIntake" to="/cover-sheets" class="nav-link">Cover Sheets</router-link>
|
||||
<router-link v-if="auth.canEntry" to="/entry" class="nav-link">Entry</router-link>
|
||||
<router-link v-if="auth.canVerify" to="/verification" class="nav-link">Verification</router-link>
|
||||
<router-link v-if="auth.canApprove" to="/approval" class="nav-link">Approval</router-link>
|
||||
|
||||
@@ -14,6 +14,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/IntakeView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: '/cover-sheets',
|
||||
name: 'CoverSheets',
|
||||
component: () => import('../views/CoverSheetView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: '/entry',
|
||||
name: 'EntryQueue',
|
||||
|
||||
@@ -71,13 +71,16 @@ export const useBatchStore = defineStore('batches', () => {
|
||||
track: string,
|
||||
patientId?: string,
|
||||
supersedesBatchId?: string,
|
||||
coverSheetCode?: string,
|
||||
): Promise<BatchDetailResponse | null> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const fields: Record<string, string> = { batchType, track }
|
||||
const fields: Record<string, string> = { track }
|
||||
if (batchType) fields.batchType = batchType
|
||||
if (patientId) fields.patientId = patientId
|
||||
if (supersedesBatchId) fields.supersedesBatchId = supersedesBatchId
|
||||
if (coverSheetCode) fields.coverSheetCode = coverSheetCode
|
||||
|
||||
const response = await uploadFile<BatchDetailResponse>(
|
||||
'digitization-batches',
|
||||
|
||||
@@ -146,6 +146,30 @@ export interface UserSummary {
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface CoverSheetResponse {
|
||||
id: string
|
||||
code: string
|
||||
batchType: string
|
||||
track: string
|
||||
patientId: string | null
|
||||
patientName: string | null
|
||||
patientMrn: string | null
|
||||
assignToUserId: string | null
|
||||
assignToUserName: string | null
|
||||
isUsed: boolean
|
||||
batchId: string | null
|
||||
createdAt: string
|
||||
usedAt: string | null
|
||||
}
|
||||
|
||||
export interface GenerateCoverSheetsRequest {
|
||||
count: number
|
||||
batchType: string
|
||||
track: string
|
||||
patientId?: string
|
||||
assignToUserId?: string
|
||||
}
|
||||
|
||||
export interface DigitizationEventSummary {
|
||||
eventType: string
|
||||
occurredAt: string
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<AppHeader title="Cover Sheets" />
|
||||
|
||||
<div class="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto flex-1 w-full">
|
||||
<h1 class="text-2xl font-bold mb-6">Cover Sheet Management</h1>
|
||||
|
||||
<!-- Generate -->
|
||||
<div class="card mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Generate Cover Sheets</h2>
|
||||
|
||||
<form @submit.prevent="handleGenerate" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Count (1–100)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
class="form-input max-w-xs"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Type</label>
|
||||
<select v-model="batchType" class="form-input" required>
|
||||
<option value="">Select batch type...</option>
|
||||
<option value="PATIENT_REGISTRATION">Patient Registration</option>
|
||||
<option value="ENCOUNTER_SUMMARY">Encounter Summary</option>
|
||||
<option value="VITALS_SHEET">Vitals Sheet</option>
|
||||
<option value="LAB_RESULTS">Lab Results</option>
|
||||
<option value="MEDICATION_LIST">Medication List</option>
|
||||
<option value="ALLERGY_UPDATE">Allergy Update</option>
|
||||
<option value="MIXED">Mixed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Track</label>
|
||||
<select v-model="track" class="form-input">
|
||||
<option value="BACKFILL">Backfill (Track A)</option>
|
||||
<option value="LIVE_CAPTURE">Live Capture (Track B)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Patient (optional)
|
||||
</label>
|
||||
<PatientSearch v-model="patientId" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Assign to Clerk (optional)
|
||||
</label>
|
||||
<select v-model="assignToUserId" class="form-input" :disabled="clerksLoading">
|
||||
<option value="">No pre-assignment</option>
|
||||
<option v-for="clerk in clerks" :key="clerk.id" :value="clerk.id">
|
||||
{{ clerk.fullName }} ({{ clerk.username }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="generateError" class="text-clinical-danger text-sm">
|
||||
{{ generateError }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="submit" class="btn-primary" :disabled="generating || !batchType">
|
||||
{{ generating ? 'Generating...' : 'Generate' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="lastGeneratedIds.length > 0"
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
:disabled="printing"
|
||||
@click="printLastGenerated"
|
||||
>
|
||||
{{ printing ? 'Opening PDF...' : 'Print Cover Sheets' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div class="card">
|
||||
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-4">
|
||||
<h2 class="text-lg font-semibold">Cover Sheet List</h2>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
|
||||
<select v-model="statusFilter" class="form-input" @change="onFiltersChanged">
|
||||
<option value="all">All</option>
|
||||
<option value="unused">Unused</option>
|
||||
<option value="used">Used</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="min-w-[240px]">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Patient</label>
|
||||
<PatientSearch v-model="filterPatientId" @update:model-value="onFiltersChanged" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="listError" class="text-clinical-danger text-sm mb-4">
|
||||
{{ listError }}
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<div v-if="listLoading" class="text-gray-500 text-center py-4">Loading...</div>
|
||||
<div v-else-if="coverSheets.length === 0" class="text-gray-500 text-center py-4">
|
||||
No cover sheets found.
|
||||
</div>
|
||||
<table v-else class="w-full min-w-[800px] text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-gray-600">
|
||||
<th class="py-2 px-4">Code</th>
|
||||
<th class="py-2 px-4">Batch Type</th>
|
||||
<th class="py-2 px-4">Track</th>
|
||||
<th class="py-2 px-4">Patient</th>
|
||||
<th class="py-2 px-4">Assigned To</th>
|
||||
<th class="py-2 px-4">Status</th>
|
||||
<th class="py-2 px-4">Linked Batch</th>
|
||||
<th class="py-2 px-4">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="sheet in coverSheets"
|
||||
:key="sheet.id"
|
||||
class="border-b hover:bg-gray-50"
|
||||
>
|
||||
<td class="py-2 px-4 font-mono text-xs">{{ sheet.code }}</td>
|
||||
<td class="py-2 px-4">{{ formatBatchType(sheet.batchType) }}</td>
|
||||
<td class="py-2 px-4">
|
||||
<span
|
||||
:class="sheet.track === 'BACKFILL'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-green-100 text-green-800'"
|
||||
class="status-badge"
|
||||
>
|
||||
{{ sheet.track === 'BACKFILL' ? 'Backfill' : 'Live' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
<template v-if="sheet.patientName">
|
||||
{{ sheet.patientName }}
|
||||
<span v-if="sheet.patientMrn" class="text-gray-500">({{ sheet.patientMrn }})</span>
|
||||
</template>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
{{ sheet.assignToUserName ?? '—' }}
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
<span
|
||||
:class="sheet.isUsed
|
||||
? 'bg-gray-100 text-gray-800'
|
||||
: 'bg-green-100 text-green-800'"
|
||||
class="status-badge"
|
||||
>
|
||||
{{ sheet.isUsed ? 'Used' : 'Unused' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
<router-link
|
||||
v-if="sheet.batchId"
|
||||
:to="{ path: '/intake', query: { batchId: sheet.batchId } }"
|
||||
class="font-mono text-xs text-primary-600 hover:text-primary-800"
|
||||
>
|
||||
{{ sheet.batchId.substring(0, 8) }}...
|
||||
</router-link>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</td>
|
||||
<td class="py-2 px-4 text-gray-500">
|
||||
{{ formatDate(sheet.createdAt) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<p class="text-sm text-gray-500">Page {{ page }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary text-sm"
|
||||
:disabled="page <= 1 || listLoading"
|
||||
@click="goToPage(page - 1)"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary text-sm"
|
||||
:disabled="!hasNextPage || listLoading"
|
||||
@click="goToPage(page + 1)"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get, post, postBlob } from '../api/client'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import PatientSearch from '../components/PatientSearch.vue'
|
||||
import type { CoverSheetResponse, UserSummary } from '../types'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const count = ref(10)
|
||||
const batchType = ref('')
|
||||
const track = ref('BACKFILL')
|
||||
const patientId = ref<string | undefined>()
|
||||
const assignToUserId = ref('')
|
||||
|
||||
const clerks = ref<UserSummary[]>([])
|
||||
const clerksLoading = ref(false)
|
||||
const generating = ref(false)
|
||||
const printing = ref(false)
|
||||
const generateError = ref('')
|
||||
const lastGeneratedIds = ref<string[]>([])
|
||||
|
||||
const coverSheets = ref<CoverSheetResponse[]>([])
|
||||
const listLoading = ref(false)
|
||||
const listError = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const statusFilter = ref<'all' | 'used' | 'unused'>('all')
|
||||
const filterPatientId = ref<string | undefined>()
|
||||
|
||||
const hasNextPage = computed(() => coverSheets.value.length === pageSize)
|
||||
|
||||
function formatBatchType(type: string): string {
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
|
||||
async function loadClerks(): Promise<void> {
|
||||
clerksLoading.value = true
|
||||
try {
|
||||
const response = await get<UserSummary[]>('users', { role: 'DATA_ENTRY_CLERK' })
|
||||
clerks.value = response.success && response.data ? response.data : []
|
||||
} catch {
|
||||
clerks.value = []
|
||||
} finally {
|
||||
clerksLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCoverSheets(): Promise<void> {
|
||||
listLoading.value = true
|
||||
listError.value = ''
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
page: page.value,
|
||||
pageSize,
|
||||
}
|
||||
|
||||
if (statusFilter.value === 'used') params.isUsed = true
|
||||
if (statusFilter.value === 'unused') params.isUsed = false
|
||||
if (filterPatientId.value) params.patientId = filterPatientId.value
|
||||
|
||||
try {
|
||||
const response = await get<CoverSheetResponse[]>('cover-sheets', params)
|
||||
if (response.success && response.data) {
|
||||
coverSheets.value = response.data
|
||||
} else {
|
||||
coverSheets.value = []
|
||||
listError.value = response.error?.message ?? 'Failed to load cover sheets'
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
coverSheets.value = []
|
||||
listError.value = e instanceof Error ? e.message : 'Failed to load cover sheets'
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFiltersChanged(): void {
|
||||
page.value = 1
|
||||
void loadCoverSheets()
|
||||
}
|
||||
|
||||
function goToPage(nextPage: number): void {
|
||||
page.value = nextPage
|
||||
void loadCoverSheets()
|
||||
}
|
||||
|
||||
async function handleGenerate(): Promise<void> {
|
||||
if (!batchType.value) return
|
||||
|
||||
generating.value = true
|
||||
generateError.value = ''
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
count: Math.min(100, Math.max(1, count.value || 10)),
|
||||
batchType: batchType.value,
|
||||
track: track.value,
|
||||
}
|
||||
|
||||
if (patientId.value) payload.patientId = patientId.value
|
||||
if (assignToUserId.value) payload.assignToUserId = assignToUserId.value
|
||||
|
||||
try {
|
||||
const response = await post<CoverSheetResponse[]>('cover-sheets/generate', payload)
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.error?.message ?? 'Generation failed')
|
||||
}
|
||||
|
||||
lastGeneratedIds.value = response.data.map(s => s.id)
|
||||
toast.success(`Generated ${response.data.length} cover sheet(s)`)
|
||||
page.value = 1
|
||||
await loadCoverSheets()
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Generation failed'
|
||||
generateError.value = msg
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function printLastGenerated(): Promise<void> {
|
||||
if (lastGeneratedIds.value.length === 0) return
|
||||
|
||||
printing.value = true
|
||||
try {
|
||||
const blob = await postBlob('cover-sheets/batch-pdf', {
|
||||
coverSheetIds: lastGeneratedIds.value,
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
window.open(url, '_blank')
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
toast.success('Cover sheet PDF opened in a new tab')
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to generate PDF')
|
||||
} finally {
|
||||
printing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadClerks(), loadCoverSheets()])
|
||||
})
|
||||
</script>
|
||||
@@ -5,6 +5,80 @@
|
||||
<div class="page-container flex-1">
|
||||
<h1 class="text-2xl font-bold mb-6">Upload Scanned Document</h1>
|
||||
|
||||
<!-- Barcode-assisted upload -->
|
||||
<div class="card mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Quick Upload with Cover Sheet</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Scan or type a cover sheet barcode to auto-populate batch details.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-4 items-end">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Cover Sheet Code
|
||||
</label>
|
||||
<input
|
||||
v-model="coverSheetCode"
|
||||
@keydown.enter.prevent="lookupCoverSheet"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="VCR-CS-XXXXXXXX"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="lookupCoverSheet"
|
||||
class="btn-secondary"
|
||||
:disabled="!coverSheetCode.trim() || lookupLoading"
|
||||
>
|
||||
{{ lookupLoading ? 'Looking up...' : 'Lookup' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="lookupError" class="text-clinical-danger text-sm mt-3">
|
||||
{{ lookupError }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="resolvedCoverSheet"
|
||||
class="mt-4 bg-green-50 border border-green-200 rounded-md p-4"
|
||||
>
|
||||
<p class="text-sm font-medium text-green-800">Cover Sheet Found</p>
|
||||
<dl class="mt-2 text-sm text-green-700 space-y-1">
|
||||
<div>
|
||||
<dt class="inline font-medium">Type:</dt>
|
||||
<dd class="inline">{{ formatBatchType(resolvedCoverSheet.batchType) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="inline font-medium">Track:</dt>
|
||||
<dd class="inline">{{ formatTrack(resolvedCoverSheet.track) }}</dd>
|
||||
</div>
|
||||
<div v-if="resolvedCoverSheet.patientName">
|
||||
<dt class="inline font-medium">Patient:</dt>
|
||||
<dd class="inline">
|
||||
{{ resolvedCoverSheet.patientName }}
|
||||
({{ resolvedCoverSheet.patientMrn }})
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="resolvedCoverSheet.assignToUserName">
|
||||
<dt class="inline font-medium">Assign to:</dt>
|
||||
<dd class="inline">{{ resolvedCoverSheet.assignToUserName }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="text-sm text-green-600 mt-3">
|
||||
Select a file below and click "Upload with Cover Sheet" to create the batch.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="clearCoverSheet"
|
||||
class="text-xs text-green-700 hover:text-green-900 mt-2"
|
||||
>
|
||||
Clear cover sheet (use manual upload)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload form -->
|
||||
<div class="card mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">New Batch</h2>
|
||||
@@ -35,7 +109,11 @@
|
||||
<!-- Batch type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Type</label>
|
||||
<select v-model="batchType" class="form-input" required>
|
||||
<select
|
||||
v-model="batchType"
|
||||
class="form-input"
|
||||
:required="!resolvedCoverSheet"
|
||||
>
|
||||
<option value="">Select batch type...</option>
|
||||
<option value="PATIENT_REGISTRATION">Patient Registration</option>
|
||||
<option value="ENCOUNTER_SUMMARY">Encounter Summary</option>
|
||||
@@ -72,6 +150,7 @@
|
||||
<span class="font-mono">{{ supersedesBatchId.substring(0, 8) }}...</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="clearCorrection"
|
||||
class="text-xs text-blue-600 hover:text-blue-800 mt-2"
|
||||
>
|
||||
@@ -86,9 +165,9 @@
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
:disabled="!selectedFile || !batchType || batchStore.loading"
|
||||
:disabled="!canUpload"
|
||||
>
|
||||
{{ batchStore.loading ? 'Uploading...' : 'Upload and Create Batch' }}
|
||||
{{ uploadButtonLabel }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -118,14 +197,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { get } from '../api/client'
|
||||
import { useBatchStore } from '../stores/batches'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import PatientSearch from '../components/PatientSearch.vue'
|
||||
import BatchList from '../components/BatchList.vue'
|
||||
import AssignClerkDialog from '../components/AssignClerkDialog.vue'
|
||||
import type { CoverSheetResponse } from '../types'
|
||||
|
||||
const route = useRoute()
|
||||
const batchStore = useBatchStore()
|
||||
@@ -141,13 +222,87 @@ const assignError = ref('')
|
||||
const assignDialogOpen = ref(false)
|
||||
const assignBatchId = ref<string | null>(null)
|
||||
|
||||
const coverSheetCode = ref('')
|
||||
const resolvedCoverSheet = ref<CoverSheetResponse | null>(null)
|
||||
const lookupLoading = ref(false)
|
||||
const lookupError = ref('')
|
||||
|
||||
const canUpload = computed(() => {
|
||||
if (!selectedFile.value || batchStore.loading) return false
|
||||
if (resolvedCoverSheet.value) return true
|
||||
return !!batchType.value
|
||||
})
|
||||
|
||||
const uploadButtonLabel = computed(() => {
|
||||
if (batchStore.loading) return 'Uploading...'
|
||||
if (resolvedCoverSheet.value) return 'Upload with Cover Sheet'
|
||||
return 'Upload and Create Batch'
|
||||
})
|
||||
|
||||
watch(coverSheetCode, () => {
|
||||
if (resolvedCoverSheet.value && coverSheetCode.value.trim().toUpperCase() !== resolvedCoverSheet.value.code) {
|
||||
resolvedCoverSheet.value = null
|
||||
lookupError.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
function formatBatchType(type: string): string {
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
function formatTrack(value: string): string {
|
||||
return value === 'BACKFILL' ? 'Backfill (Track A)' : 'Live Capture (Track B)'
|
||||
}
|
||||
|
||||
function onFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
selectedFile.value = input.files?.[0] ?? null
|
||||
}
|
||||
|
||||
async function lookupCoverSheet(): Promise<void> {
|
||||
const code = coverSheetCode.value.trim().toUpperCase()
|
||||
if (!code) return
|
||||
|
||||
lookupLoading.value = true
|
||||
lookupError.value = ''
|
||||
resolvedCoverSheet.value = null
|
||||
|
||||
try {
|
||||
const response = await get<CoverSheetResponse>(
|
||||
`cover-sheets/lookup/${encodeURIComponent(code)}`,
|
||||
)
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.error?.message ?? 'Cover sheet not found')
|
||||
}
|
||||
|
||||
if (response.data.isUsed) {
|
||||
throw new Error('Cover sheet has already been used')
|
||||
}
|
||||
|
||||
resolvedCoverSheet.value = response.data
|
||||
coverSheetCode.value = response.data.code
|
||||
batchType.value = response.data.batchType
|
||||
track.value = response.data.track
|
||||
patientId.value = response.data.patientId ?? undefined
|
||||
toast.success('Cover sheet found')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Lookup failed'
|
||||
lookupError.value = msg
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
lookupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearCoverSheet(): void {
|
||||
coverSheetCode.value = ''
|
||||
resolvedCoverSheet.value = null
|
||||
lookupError.value = ''
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!selectedFile.value || !batchType.value) return
|
||||
if (!selectedFile.value || !canUpload.value) return
|
||||
|
||||
uploadError.value = ''
|
||||
try {
|
||||
@@ -157,15 +312,24 @@ async function handleUpload() {
|
||||
track.value,
|
||||
patientId.value,
|
||||
supersedesBatchId.value,
|
||||
resolvedCoverSheet.value?.code,
|
||||
)
|
||||
if (batch) {
|
||||
const isCorrection = !!supersedesBatchId.value
|
||||
const usedCoverSheet = !!resolvedCoverSheet.value
|
||||
selectedFile.value = null
|
||||
batchType.value = ''
|
||||
track.value = 'BACKFILL'
|
||||
patientId.value = undefined
|
||||
supersedesBatchId.value = undefined
|
||||
toast.success(isCorrection ? 'Correction batch created' : 'Batch uploaded successfully')
|
||||
clearCoverSheet()
|
||||
toast.success(
|
||||
isCorrection
|
||||
? 'Correction batch created'
|
||||
: usedCoverSheet
|
||||
? 'Batch uploaded with cover sheet'
|
||||
: 'Batch uploaded successfully',
|
||||
)
|
||||
await loadRecent()
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/__tests__/setup.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user