feature: Barcode/QR Cover Sheet System

This commit is contained in:
voltsrage
2026-06-27 21:50:32 +08:00
parent 5db60f46eb
commit 756cff332c
43 changed files with 8203 additions and 21 deletions
@@ -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 IDocumentStorageService _storage;
private readonly IPromotionService _promotion; private readonly IPromotionService _promotion;
private readonly IBatchEventService _batchEventService; private readonly IBatchEventService _batchEventService;
private readonly ICoverSheetService _coverSheets;
private readonly AppDbContext _db; private readonly AppDbContext _db;
private static readonly HashSet<string> _allowedMimeTypes = new() private static readonly HashSet<string> _allowedMimeTypes = new()
@@ -29,13 +30,15 @@ public class DigitizationBatchesController : ControllerBase
IDocumentStorageService storage, IDocumentStorageService storage,
IPromotionService promotion, IPromotionService promotion,
IBatchEventService batchEventService, IBatchEventService batchEventService,
AppDbContext db) AppDbContext db,
ICoverSheetService coverSheets)
{ {
_batches = batches; _batches = batches;
_storage = storage; _storage = storage;
_promotion = promotion; _promotion = promotion;
_batchEventService = batchEventService; _batchEventService = batchEventService;
_db = db; _db = db;
_coverSheets = coverSheets;
} }
/// <summary> /// <summary>
@@ -60,10 +63,40 @@ public class DigitizationBatchesController : ControllerBase
return BadRequest(ApiResponse<object>.Fail(400, return BadRequest(ApiResponse<object>.Fail(400,
"Accepted formats: PDF, JPEG, PNG.", "INVALID_MIME_TYPE")); "Accepted formats: PDF, JPEG, PNG.", "INVALID_MIME_TYPE"));
var parsedBatchType = BatchTypeExtensions.FromDbString(form.BatchType.ToUpperInvariant()); CoverSheet? coverSheet = null;
var parsedTrack = string.IsNullOrEmpty(form.Track) 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 ? BatchTrack.Backfill
: BatchTrackExtensions.FromDbString(form.Track.ToUpperInvariant()); : BatchTrackExtensions.FromDbString(form.Track.ToUpperInvariant());
}
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
using var stream = form.File.OpenReadStream(); using var stream = form.File.OpenReadStream();
@@ -71,8 +104,17 @@ public class DigitizationBatchesController : ControllerBase
stream, form.File.ContentType, parsedBatchType, parsedTrack, stream, form.File.ContentType, parsedBatchType, parsedTrack,
form.PatientId, form.SupersedesBatchId, actorUserId); 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( return StatusCode(201, ApiResponse<BatchDetailResponse>.Created(
BatchDetailResponse.FromEntity(result.Batch, supersession: result.Supersession))); BatchDetailResponse.FromEntity(batch, supersession: result.Supersession)));
} }
/// <summary> /// <summary>
+1
View File
@@ -23,6 +23,7 @@ public class AppDbContext : DbContext
public DbSet<LiveEncounter> LiveEncounters => Set<LiveEncounter>(); public DbSet<LiveEncounter> LiveEncounters => Set<LiveEncounter>();
public DbSet<LiveObservation> LiveObservations => Set<LiveObservation>(); public DbSet<LiveObservation> LiveObservations => Set<LiveObservation>();
public DbSet<PromotionAttempt> PromotionAttempts => Set<PromotionAttempt>(); public DbSet<PromotionAttempt> PromotionAttempts => Set<PromotionAttempt>();
public DbSet<CoverSheet> CoverSheets => Set<CoverSheet>();
protected override void OnModelCreating(ModelBuilder modelBuilder) 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);
}
}
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 => modelBuilder.Entity("DigitizationBatch", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1269,6 +1348,38 @@ namespace VigilCareRecordsAPI.Data.Migrations
b.Navigation("User"); 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 => modelBuilder.Entity("DigitizationBatch", b =>
{ {
b.HasOne("User", "ApprovedByUser") 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] [Required]
public IFormFile File { get; set; } = null!; public IFormFile File { get; set; } = null!;
[Required] /// <summary>
public string BatchType { get; set; } = null!; /// 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; } public string? Track { get; set; }
@@ -15,5 +17,8 @@ public class CreateBatchForm
public Guid? SupersedesBatchId { get; set; } public Guid? SupersedesBatchId { get; set; }
public CreateBatchRequest ToMetadata() => 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
);
+1
View File
@@ -121,6 +121,7 @@ try
builder.Services.AddScoped<IAttestationService, AttestationService>(); builder.Services.AddScoped<IAttestationService, AttestationService>();
builder.Services.AddScoped<ILiveCaptureService, LiveCaptureService>(); builder.Services.AddScoped<ILiveCaptureService, LiveCaptureService>();
builder.Services.AddScoped<IBatchEventService, BatchEventService>(); builder.Services.AddScoped<IBatchEventService, BatchEventService>();
builder.Services.AddScoped<ICoverSheetService, CoverSheetService>();
builder.Services.AddHostedService<MetricsCollectorService>(); builder.Services.AddHostedService<MetricsCollectorService>();
builder.Services.AddHostedService<PromotionRetryService>(); 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="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="prometheus-net" Version="8.2.1" /> <PackageReference Include="prometheus-net" Version="8.2.1" />
<PackageReference Include="prometheus-net.AspNetCore" 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.AspNetCore" Version="8.0.2" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" /> <PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" /> <PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />
+2 -2
View File
@@ -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 ### 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 | | 19 | EntryForm missing allergy/med fields | P4 | E | Open |
| 20 | No corrections/supersession UI | P4 | E | Open | | 20 | No corrections/supersession UI | P4 | E | Open |
| 21 | No toast/notification system | 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 | | 23 | No field-level draft audit | P5 | F | Open |
| 24 | Integration test gaps | P5 | F | Done | | 24 | Integration test gaps | P5 | F | Done |
| 25 | MetricsCollector missing retry gauges | P5 | F | Done | | 25 | MetricsCollector missing retry gauges | P5 | F | Done |
+751
View File
@@ -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 19 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 "$@"
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -6,7 +6,9 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
}, },
"dependencies": { "dependencies": {
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.3.0",
@@ -16,14 +18,18 @@
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@types/jsdom": "^28.0.3",
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue": "^6.0.7",
"@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"autoprefixer": "^10.5.2", "autoprefixer": "^10.5.2",
"jsdom": "^29.1.1",
"postcss": "^8.5.15", "postcss": "^8.5.15",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.1.0", "vite": "^8.1.0",
"vitest": "^4.1.9",
"vue-tsc": "^3.3.5" "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.')
})
})
+9
View File
@@ -174,4 +174,13 @@ export async function uploadFile<T>(
return response.data 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 export default apiClient
@@ -8,6 +8,11 @@
hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors; 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 { .btn-danger {
@apply bg-clinical-danger text-white px-4 py-2 rounded-md @apply bg-clinical-danger text-white px-4 py-2 rounded-md
hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed
@@ -4,6 +4,7 @@
<h1 class="text-lg font-semibold">{{ title }}</h1> <h1 class="text-lg font-semibold">{{ title }}</h1>
<nav class="flex gap-3 ml-4"> <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="/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.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.canVerify" to="/verification" class="nav-link">Verification</router-link>
<router-link v-if="auth.canApprove" to="/approval" class="nav-link">Approval</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'), component: () => import('../views/IntakeView.vue'),
meta: { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] }, 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', path: '/entry',
name: 'EntryQueue', name: 'EntryQueue',
+4 -1
View File
@@ -71,13 +71,16 @@ export const useBatchStore = defineStore('batches', () => {
track: string, track: string,
patientId?: string, patientId?: string,
supersedesBatchId?: string, supersedesBatchId?: string,
coverSheetCode?: string,
): Promise<BatchDetailResponse | null> { ): Promise<BatchDetailResponse | null> {
loading.value = true loading.value = true
error.value = null error.value = null
try { 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 (patientId) fields.patientId = patientId
if (supersedesBatchId) fields.supersedesBatchId = supersedesBatchId if (supersedesBatchId) fields.supersedesBatchId = supersedesBatchId
if (coverSheetCode) fields.coverSheetCode = coverSheetCode
const response = await uploadFile<BatchDetailResponse>( const response = await uploadFile<BatchDetailResponse>(
'digitization-batches', 'digitization-batches',
+24
View File
@@ -146,6 +146,30 @@ export interface UserSummary {
role: string 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 { export interface DigitizationEventSummary {
eventType: string eventType: string
occurredAt: 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&ndash;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">&mdash;</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">&mdash;</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>
+170 -6
View File
@@ -5,6 +5,80 @@
<div class="page-container flex-1"> <div class="page-container flex-1">
<h1 class="text-2xl font-bold mb-6">Upload Scanned Document</h1> <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 --> <!-- Upload form -->
<div class="card mb-6"> <div class="card mb-6">
<h2 class="text-lg font-semibold mb-4">New Batch</h2> <h2 class="text-lg font-semibold mb-4">New Batch</h2>
@@ -35,7 +109,11 @@
<!-- Batch type --> <!-- Batch type -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Type</label> <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="">Select batch type...</option>
<option value="PATIENT_REGISTRATION">Patient Registration</option> <option value="PATIENT_REGISTRATION">Patient Registration</option>
<option value="ENCOUNTER_SUMMARY">Encounter Summary</option> <option value="ENCOUNTER_SUMMARY">Encounter Summary</option>
@@ -72,6 +150,7 @@
<span class="font-mono">{{ supersedesBatchId.substring(0, 8) }}...</span> <span class="font-mono">{{ supersedesBatchId.substring(0, 8) }}...</span>
</p> </p>
<button <button
type="button"
@click="clearCorrection" @click="clearCorrection"
class="text-xs text-blue-600 hover:text-blue-800 mt-2" class="text-xs text-blue-600 hover:text-blue-800 mt-2"
> >
@@ -86,9 +165,9 @@
<button <button
type="submit" type="submit"
class="btn-primary" class="btn-primary"
:disabled="!selectedFile || !batchType || batchStore.loading" :disabled="!canUpload"
> >
{{ batchStore.loading ? 'Uploading...' : 'Upload and Create Batch' }} {{ uploadButtonLabel }}
</button> </button>
</form> </form>
</div> </div>
@@ -118,14 +197,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { get } from '../api/client'
import { useBatchStore } from '../stores/batches' import { useBatchStore } from '../stores/batches'
import { useToast } from '../composables/useToast' import { useToast } from '../composables/useToast'
import AppHeader from '../components/AppHeader.vue' import AppHeader from '../components/AppHeader.vue'
import PatientSearch from '../components/PatientSearch.vue' import PatientSearch from '../components/PatientSearch.vue'
import BatchList from '../components/BatchList.vue' import BatchList from '../components/BatchList.vue'
import AssignClerkDialog from '../components/AssignClerkDialog.vue' import AssignClerkDialog from '../components/AssignClerkDialog.vue'
import type { CoverSheetResponse } from '../types'
const route = useRoute() const route = useRoute()
const batchStore = useBatchStore() const batchStore = useBatchStore()
@@ -141,13 +222,87 @@ const assignError = ref('')
const assignDialogOpen = ref(false) const assignDialogOpen = ref(false)
const assignBatchId = ref<string | null>(null) 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) { function onFileChange(event: Event) {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
selectedFile.value = input.files?.[0] ?? null 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() { async function handleUpload() {
if (!selectedFile.value || !batchType.value) return if (!selectedFile.value || !canUpload.value) return
uploadError.value = '' uploadError.value = ''
try { try {
@@ -157,15 +312,24 @@ async function handleUpload() {
track.value, track.value,
patientId.value, patientId.value,
supersedesBatchId.value, supersedesBatchId.value,
resolvedCoverSheet.value?.code,
) )
if (batch) { if (batch) {
const isCorrection = !!supersedesBatchId.value const isCorrection = !!supersedesBatchId.value
const usedCoverSheet = !!resolvedCoverSheet.value
selectedFile.value = null selectedFile.value = null
batchType.value = '' batchType.value = ''
track.value = 'BACKFILL' track.value = 'BACKFILL'
patientId.value = undefined patientId.value = undefined
supersedesBatchId.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() await loadRecent()
} }
} catch (e: unknown) { } catch (e: unknown) {
+17
View File
@@ -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'],
},
})