mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
Editing a work order's POC never reached the backend: no update path wrote
PocName/PocPhone/PocNotes, so the optimistic UI edit was lost on refetch and
the completion freeze captured the Site contact instead of the manual value,
and nothing was audited.
- Add tenant-scoped PATCH api/workorders/{id}/poc via new WorkOrderPocService
+ WorkOrderPocDataService: persists the override, stages FieldChanged audit
entries (which also write field locks so sync never overwrites a manual POC),
and enforces row-version concurrency and terminal-status read-only rules.
- Lock semantics (SH-190): a manual POC away from the Site's live contacts is
stored WO-level; an edit equal to a live Site contact (or blanking name+phone)
stores nothing so the WO follows the Site. PocCustomized exposes the state.
- Board projection, completion freeze and create now share one Site-contact
fallback (first non-deleted contact by SiteContactOrder) so a never-overridden
WO keeps following the Site, including at create when the wizard prefills it.
- Route contract baseline gains PATCH {id:int}/poc.
855 lines
33 KiB
C#
855 lines
33 KiB
C#
using System.Security.Claims;
|
|
using FluentValidation;
|
|
using Data.SeaHavenIndustries;
|
|
using Data.SeaHavenIndustries.Enums;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using SeaHaven.DataServices.Implementation;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Exceptions;
|
|
using SeaHaven.Services.Helpers;
|
|
using SeaHaven.Services.Implementation;
|
|
using SeaHaven.Services.Validation;
|
|
using Xunit;
|
|
|
|
namespace SeaHavenIndustries.Tests;
|
|
|
|
public class WorkOrderAccountScopeTests
|
|
{
|
|
private static ApplicationDbContext CreateContext()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
return new ApplicationDbContext(options);
|
|
}
|
|
|
|
private static WorkOrderBoardCreateService CreateBoardCreate(ApplicationDbContext context)
|
|
{
|
|
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
|
var boardData = new WorkOrderBoardDataService(context);
|
|
var mutationData = new WorkOrderBoardMutationDataService(context);
|
|
var boardService = new WorkOrderBoardService(boardData, resolver);
|
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
|
return new WorkOrderBoardCreateService(
|
|
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver, new WorkOrderPocDataService(context));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BoardCreate_WithAccountClaim_StampsAccountId()
|
|
{
|
|
await using var context = CreateContext();
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 7, "Scoped Co");
|
|
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 11, accountId: 7);
|
|
|
|
var create = CreateBoardCreate(context);
|
|
var user = WorkOrderAccountTestHelpers.AccountUser("actor-1", 7, "Dispatcher");
|
|
var row = await create.CreateAsync(
|
|
new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5",
|
|
LocationId = 11
|
|
},
|
|
user,
|
|
"actor-1");
|
|
|
|
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
|
|
Assert.Equal(7, wo.AccountId);
|
|
Assert.Equal(11, wo.LocationId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BoardCreate_MissingScope_ThrowsForbidden()
|
|
{
|
|
await using var context = CreateContext();
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
|
|
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 11, accountId: 1);
|
|
var create = CreateBoardCreate(context);
|
|
|
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
|
create.CreateAsync(
|
|
new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5",
|
|
LocationId = 11
|
|
},
|
|
WorkOrderAccountTestHelpers.MissingScope(),
|
|
"actor-1"));
|
|
|
|
Assert.Equal("Forbidden", ex.Code);
|
|
Assert.Empty(context.workOrders);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BoardCreate_OrgWide_WithLocationAccount_StampsAccountId()
|
|
{
|
|
await using var context = CreateContext();
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 3, "Unique Customer");
|
|
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 22, accountId: 3, name: "DAL");
|
|
|
|
var create = CreateBoardCreate(context);
|
|
var row = await create.CreateAsync(
|
|
new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PO,
|
|
SiteCode = "DAL",
|
|
LocationId = 22,
|
|
Customer = "Ignored Client Customer"
|
|
},
|
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
|
"admin-1");
|
|
|
|
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
|
|
Assert.Equal(3, wo.AccountId);
|
|
Assert.Equal(22, wo.LocationId);
|
|
Assert.Equal("Ignored Client Customer", wo.Customer);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BoardCreate_OrgWide_LocationWithoutAccount_ThrowsAccountUnresolved()
|
|
{
|
|
await using var context = CreateContext();
|
|
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 33, accountId: null);
|
|
var create = CreateBoardCreate(context);
|
|
|
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
|
create.CreateAsync(
|
|
new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5",
|
|
LocationId = 33
|
|
},
|
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
|
"admin-1"));
|
|
|
|
Assert.Equal("AccountUnresolved", ex.Code);
|
|
Assert.Empty(context.workOrders);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BoardCreate_OrgWide_MissingLocation_ThrowsNotFound()
|
|
{
|
|
await using var context = CreateContext();
|
|
var create = CreateBoardCreate(context);
|
|
|
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
|
create.CreateAsync(
|
|
new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5",
|
|
LocationId = 404
|
|
},
|
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
|
"admin-1"));
|
|
|
|
Assert.Equal("NotFound", ex.Code);
|
|
Assert.Empty(context.workOrders);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BoardCreate_MissingLocationId_ThrowsValidation()
|
|
{
|
|
await using var context = CreateContext();
|
|
var create = CreateBoardCreate(context);
|
|
|
|
await Assert.ThrowsAsync<ValidationException>(() =>
|
|
create.CreateAsync(
|
|
new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5"
|
|
},
|
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
|
"admin-1"));
|
|
|
|
Assert.Empty(context.workOrders);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BoardCreate_Scoped_LocationOfOtherAccount_ThrowsForbidden()
|
|
{
|
|
await using var context = CreateContext();
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
|
|
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 50, accountId: 2);
|
|
var create = CreateBoardCreate(context);
|
|
|
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
|
create.CreateAsync(
|
|
new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5",
|
|
LocationId = 50
|
|
},
|
|
WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"),
|
|
"disp-1"));
|
|
|
|
Assert.Equal("Forbidden", ex.Code);
|
|
Assert.Empty(context.workOrders);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BoardCreate_OrgWide_ServiceOmitted_Succeeds()
|
|
{
|
|
await using var context = CreateContext();
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 3, "Org");
|
|
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 60, accountId: 3);
|
|
var create = CreateBoardCreate(context);
|
|
|
|
var row = await create.CreateAsync(
|
|
new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5",
|
|
LocationId = 60
|
|
},
|
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
|
"admin-1");
|
|
|
|
Assert.Null(row.Pm);
|
|
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
|
|
Assert.Equal(3, wo.AccountId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BoardCreate_ForwardsCancellationToken_ToLocationAccountLookup()
|
|
{
|
|
await using var context = CreateContext();
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 7, "Scoped Co");
|
|
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 11, accountId: 7);
|
|
|
|
var locations = new RecordingLocationDataService(new LocationDataService(context));
|
|
var resolver = new WorkOrderAccountResolver(new AccountDataService(context), locations);
|
|
var boardData = new WorkOrderBoardDataService(context);
|
|
var mutationData = new WorkOrderBoardMutationDataService(context);
|
|
var boardService = new WorkOrderBoardService(boardData, resolver);
|
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
|
var create = new WorkOrderBoardCreateService(
|
|
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver, new WorkOrderPocDataService(context));
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
await create.CreateAsync(
|
|
new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5",
|
|
LocationId = 11
|
|
},
|
|
WorkOrderAccountTestHelpers.AccountUser("actor-1", 7, "Dispatcher"),
|
|
"actor-1",
|
|
cts.Token);
|
|
|
|
Assert.Equal(cts.Token, locations.LastScopeToken);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Board_ScopedUser_HidesOtherAccountAndNullAccountRows()
|
|
{
|
|
await using var context = CreateContext();
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
|
|
|
|
context.workOrders.AddRange(
|
|
new WorkOrder
|
|
{
|
|
Id = 1,
|
|
InternalWONumber = "00000000001",
|
|
AccountId = 1,
|
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
|
ScheduledDate = new DateTime(2026, 8, 10),
|
|
istemplate = false
|
|
},
|
|
new WorkOrder
|
|
{
|
|
Id = 2,
|
|
InternalWONumber = "00000000002",
|
|
AccountId = 2,
|
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
|
ScheduledDate = new DateTime(2026, 8, 11),
|
|
istemplate = false
|
|
},
|
|
new WorkOrder
|
|
{
|
|
Id = 3,
|
|
InternalWONumber = "00000000003",
|
|
AccountId = null,
|
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
|
ScheduledDate = new DateTime(2026, 8, 12),
|
|
istemplate = false
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
|
var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context), resolver);
|
|
|
|
var scoped = await boardService.GetBoardAsync(
|
|
new WorkOrderBoardQueryDto
|
|
{
|
|
WeekStart = new DateOnly(2026, 8, 10),
|
|
WeekEnd = new DateOnly(2026, 8, 14)
|
|
},
|
|
WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"),
|
|
"disp-1");
|
|
|
|
Assert.Single(scoped.Scheduled);
|
|
Assert.Equal(1, scoped.Scheduled[0].Id);
|
|
|
|
var orgWide = await boardService.GetBoardAsync(
|
|
new WorkOrderBoardQueryDto
|
|
{
|
|
WeekStart = new DateOnly(2026, 8, 10),
|
|
WeekEnd = new DateOnly(2026, 8, 14)
|
|
},
|
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
|
"admin-1");
|
|
|
|
Assert.Equal(3, orgWide.Scheduled.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Detail_CrossAccount_ReturnsNull()
|
|
{
|
|
await using var context = CreateContext();
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
|
|
|
|
context.workOrders.Add(new WorkOrder
|
|
{
|
|
Id = 10,
|
|
InternalWONumber = "00000000010",
|
|
AccountId = 2,
|
|
LifecycleStatus = LifecycleStatus.Incomplete,
|
|
istemplate = false
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
|
var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context), resolver);
|
|
var detail = new WorkOrderDetailService(
|
|
boardService,
|
|
new WorkOrderDetailDataService(context),
|
|
new CompletionDocTemplateDataService(context),
|
|
new UserDataService(context),
|
|
resolver);
|
|
|
|
var result = await detail.GetDetailAsync(
|
|
10,
|
|
WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"));
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ingest_UnresolvedCustomer_SkipsCreate()
|
|
{
|
|
await using var context = CreateContext();
|
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
|
var ingest = new WorkOrderIngestService(
|
|
new WorkOrderIngestDataService(context),
|
|
new SyncFieldMergePolicy(fieldLocks),
|
|
fieldLocks,
|
|
audit,
|
|
WorkOrderAccountTestHelpers.Resolver(context));
|
|
|
|
var result = await ingest.UpsertBatchAsync(new[]
|
|
{
|
|
new WorkOrderIngestPayloadDto
|
|
{
|
|
ExternalWorkOrderId = "EXT-1",
|
|
Description = "No account",
|
|
Customer = "Unknown Customer"
|
|
}
|
|
});
|
|
|
|
Assert.Equal(1, result.Skipped);
|
|
Assert.Equal(0, result.Created);
|
|
Assert.Empty(context.workOrders);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ingest_UniqueCustomer_StampsAccountId()
|
|
{
|
|
await using var context = CreateContext();
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 9, "Ingest Customer");
|
|
|
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
|
var ingest = new WorkOrderIngestService(
|
|
new WorkOrderIngestDataService(context),
|
|
new SyncFieldMergePolicy(fieldLocks),
|
|
fieldLocks,
|
|
audit,
|
|
WorkOrderAccountTestHelpers.Resolver(context));
|
|
|
|
var result = await ingest.UpsertBatchAsync(new[]
|
|
{
|
|
new WorkOrderIngestPayloadDto
|
|
{
|
|
ExternalWorkOrderId = "EXT-9",
|
|
Description = "Has account",
|
|
Customer = "Ingest Customer"
|
|
}
|
|
});
|
|
|
|
Assert.Equal(1, result.Created);
|
|
var wo = Assert.Single(context.workOrders);
|
|
Assert.Equal(9, wo.AccountId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveAccountFilter_MissingScope_ThrowsForbidden()
|
|
{
|
|
await using var context = CreateContext();
|
|
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
|
|
|
var ex = Assert.Throws<WorkOrderBoardValidationException>(() =>
|
|
resolver.ResolveAccountFilter(WorkOrderAccountTestHelpers.MissingScope()));
|
|
|
|
Assert.Equal("Forbidden", ex.Code);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LegacyList_ScopedUser_HidesOtherAccountAndNullAccountRows()
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedThreeAccountRowsAsync(context);
|
|
|
|
var data = new WorkOrderDataService(context);
|
|
var scoped = (await data.GetAllWithDetailsAsync(accountId: 1)).ToList();
|
|
var dd = await data.GetNonTemplateWorkOrdersWithLocationsAsync(accountId: 1);
|
|
|
|
Assert.Single(scoped);
|
|
Assert.Equal(1, scoped[0].Id);
|
|
Assert.Single(dd);
|
|
Assert.Equal(1, dd[0].Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LegacyList_OrgWide_IncludesNullAccountRows()
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedThreeAccountRowsAsync(context);
|
|
|
|
var data = new WorkOrderDataService(context);
|
|
var orgWide = (await data.GetAllWithDetailsAsync(accountId: null)).ToList();
|
|
|
|
Assert.Equal(3, orgWide.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LegacyDetail_CrossAccount_ReturnsNull()
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedThreeAccountRowsAsync(context);
|
|
|
|
var data = new WorkOrderDataService(context);
|
|
var detail = await data.GetWorkOrderDetailAsync(2, accountId: 1);
|
|
|
|
Assert.Null(detail);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LegacyDetail_SameAccount_ReturnsRow()
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedThreeAccountRowsAsync(context);
|
|
|
|
var data = new WorkOrderDataService(context);
|
|
var detail = await data.GetWorkOrderDetailAsync(1, accountId: 1);
|
|
|
|
Assert.NotNull(detail);
|
|
Assert.Equal(1, detail!.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LegacyDetail_OrgWide_CanReadNullAccountRow()
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedThreeAccountRowsAsync(context);
|
|
|
|
var data = new WorkOrderDataService(context);
|
|
var detail = await data.GetWorkOrderDetailAsync(3, accountId: null);
|
|
|
|
Assert.NotNull(detail);
|
|
Assert.Equal(3, detail!.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LegacyFiltered_ScopedUser_HidesOtherAccountAndNullAccountRows()
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedThreeAccountRowsAsync(context);
|
|
|
|
var service = CreateLegacyReadService(context);
|
|
var page = await service.GetFilteredWorkOrdersAsync(
|
|
Array.Empty<string>(), null, null, null, null,
|
|
search: "", sort: "", sortby: "", page: 1, pageSize: 50, accountId: 1);
|
|
|
|
Assert.Equal(1, page.TotalCount);
|
|
Assert.Single(page.Data);
|
|
Assert.Equal(1, page.Data.First().Id);
|
|
}
|
|
|
|
private static WorkOrderService CreateLegacyReadService(ApplicationDbContext context)
|
|
{
|
|
return new WorkOrderService(
|
|
new WorkOrderDataService(context),
|
|
new CommentDataService(context),
|
|
new UserDataService(context),
|
|
new QuotesDataService(context),
|
|
fileStorage: null!,
|
|
new CreateWorkOrderValidation(),
|
|
new UpdateWorkOrderValidation(),
|
|
WorkOrderAccountTestHelpers.Resolver(context));
|
|
}
|
|
|
|
private static async Task SeedThreeAccountRowsAsync(ApplicationDbContext context)
|
|
{
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
|
|
|
|
context.workOrders.AddRange(
|
|
new WorkOrder
|
|
{
|
|
Id = 1,
|
|
InternalWONumber = "00000000001",
|
|
AccountId = 1,
|
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
|
istemplate = false
|
|
},
|
|
new WorkOrder
|
|
{
|
|
Id = 2,
|
|
InternalWONumber = "00000000002",
|
|
AccountId = 2,
|
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
|
istemplate = false
|
|
},
|
|
new WorkOrder
|
|
{
|
|
Id = 3,
|
|
InternalWONumber = "00000000003",
|
|
AccountId = null,
|
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
|
istemplate = false
|
|
});
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Comments_CrossAccount_GetAddUpdate_ReturnNotFound()
|
|
{
|
|
await using var context = CreateContext();
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
|
|
|
|
context.Users.Add(new ApplicationUser
|
|
{
|
|
Id = "author-1",
|
|
UserName = "author",
|
|
FirstName = "Ann",
|
|
LastName = "Author"
|
|
});
|
|
context.workOrders.Add(new WorkOrder
|
|
{
|
|
Id = 20,
|
|
InternalWONumber = "00000000020",
|
|
AccountId = 2,
|
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
|
istemplate = false
|
|
});
|
|
context.Comments.Add(new Comments
|
|
{
|
|
Id = 5,
|
|
WorkerOrderId = 20,
|
|
UserId = "author-1",
|
|
Commenttext = "Secret",
|
|
CommentType = "General",
|
|
RecordType = "WorkOrder",
|
|
CreatedDate = DateTime.UtcNow
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var service = new WorkOrderCommentService(
|
|
new WorkOrderDetailDataService(context),
|
|
new CommentDataService(context),
|
|
new UserDataService(context),
|
|
WorkOrderAccountTestHelpers.Resolver(context));
|
|
|
|
var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher");
|
|
|
|
Assert.Null(await service.GetCommentsAsync(20, scoped));
|
|
|
|
var addEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
|
service.AddCommentAsync(20, new WorkOrderCommentCreateDto { Text = "Nope" }, scoped, "disp-1"));
|
|
Assert.Equal("NotFound", addEx.Code);
|
|
|
|
var updateEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
|
service.UpdateCommentAsync(
|
|
20, 5, new WorkOrderCommentCreateDto { Text = "Nope" }, scoped, "author-1"));
|
|
Assert.Equal("NotFound", updateEx.Code);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Comments_SameAccount_AddSucceeds()
|
|
{
|
|
await using var context = CreateContext();
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
|
|
|
|
context.Users.Add(new ApplicationUser
|
|
{
|
|
Id = "disp-1",
|
|
UserName = "disp",
|
|
FirstName = "Dee",
|
|
LastName = "Spatch"
|
|
});
|
|
context.workOrders.Add(new WorkOrder
|
|
{
|
|
Id = 21,
|
|
InternalWONumber = "00000000021",
|
|
AccountId = 1,
|
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
|
istemplate = false
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var service = new WorkOrderCommentService(
|
|
new WorkOrderDetailDataService(context),
|
|
new CommentDataService(context),
|
|
new UserDataService(context),
|
|
WorkOrderAccountTestHelpers.Resolver(context));
|
|
|
|
var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher");
|
|
var result = await service.AddCommentAsync(
|
|
21, new WorkOrderCommentCreateDto { Text = "In scope" }, scoped, "disp-1");
|
|
|
|
Assert.Equal("In scope", result.Text);
|
|
Assert.Equal("disp-1", result.AuthorId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LegacyCommentsByWorkOrder_CrossAccount_ReturnsNull()
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedThreeAccountRowsAsync(context);
|
|
context.Comments.Add(new Comments
|
|
{
|
|
WorkerOrderId = 2,
|
|
Commenttext = "Other account",
|
|
CreatedDate = DateTime.UtcNow
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var service = CreateLegacyReadService(context);
|
|
var result = await service.GetCommentsByWorkorderIdAsync(
|
|
2, WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"));
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompletionDoc_CrossAccount_EnsureAndUpload_ThrowNotFound()
|
|
{
|
|
await using var context = CreateContext();
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
|
|
|
|
context.workOrders.Add(new WorkOrder
|
|
{
|
|
Id = 30,
|
|
InternalWONumber = "00000000030",
|
|
AccountId = 2,
|
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
|
DocStatus = DocStatus.No,
|
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 },
|
|
istemplate = false
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
|
var service = new WorkOrderCompletionService(
|
|
new WorkOrderCompletionDataService(context),
|
|
new CompletionDocTemplateDataService(context),
|
|
new WorkOrderDetailDataService(context),
|
|
audit,
|
|
WorkOrderAccountTestHelpers.Resolver(context));
|
|
|
|
var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher");
|
|
|
|
var ensureEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
|
service.EnsureCanUploadCompletionDocAsync(30, scoped, "disp-1"));
|
|
Assert.Equal("NotFound", ensureEx.Code);
|
|
|
|
var uploadEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
|
service.UploadCompletionDocAsync(
|
|
30,
|
|
new WorkOrderCompletionDocUploadDto
|
|
{
|
|
WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
|
|
},
|
|
"https://example.com/should-not-stick.pdf",
|
|
scoped,
|
|
"disp-1"));
|
|
Assert.Equal("NotFound", uploadEx.Code);
|
|
|
|
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == 30);
|
|
Assert.Null(wo.SignOffAttachment);
|
|
Assert.Equal(DocStatus.No, wo.DocStatus);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompletionDoc_SameAccount_EnsureAllowsUpload()
|
|
{
|
|
await using var context = CreateContext();
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
|
|
|
|
var rowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 };
|
|
context.workOrders.Add(new WorkOrder
|
|
{
|
|
Id = 31,
|
|
InternalWONumber = "00000000031",
|
|
AccountId = 1,
|
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
|
DocStatus = DocStatus.No,
|
|
RowVersion = rowVersion,
|
|
istemplate = false
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
|
var service = new WorkOrderCompletionService(
|
|
new WorkOrderCompletionDataService(context),
|
|
new CompletionDocTemplateDataService(context),
|
|
new WorkOrderDetailDataService(context),
|
|
audit,
|
|
WorkOrderAccountTestHelpers.Resolver(context));
|
|
|
|
var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher");
|
|
await service.EnsureCanUploadCompletionDocAsync(31, scoped, "disp-1");
|
|
|
|
var result = await service.UploadCompletionDocAsync(
|
|
31,
|
|
new WorkOrderCompletionDocUploadDto
|
|
{
|
|
WorkOrderVersion = Convert.ToBase64String(rowVersion)
|
|
},
|
|
"https://example.com/ok.pdf",
|
|
scoped,
|
|
"disp-1");
|
|
|
|
Assert.Equal(DocStatus.Yes, result.DocStatus);
|
|
Assert.Equal("https://example.com/ok.pdf", result.SignOffAttachment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LegacyGetComments_ScopedUser_HidesOtherAccountComments()
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedThreeAccountRowsAsync(context);
|
|
|
|
context.Comments.AddRange(
|
|
new Comments
|
|
{
|
|
WorkerOrderId = 1,
|
|
Commenttext = "Account A note",
|
|
Documents = "a.pdf",
|
|
CreatedDate = DateTime.UtcNow
|
|
},
|
|
new Comments
|
|
{
|
|
WorkerOrderId = 2,
|
|
Commenttext = "Account B secret",
|
|
Documents = "b.pdf",
|
|
CreatedDate = DateTime.UtcNow
|
|
},
|
|
new Comments
|
|
{
|
|
WorkerOrderId = 3,
|
|
Commenttext = "Null account note",
|
|
CreatedDate = DateTime.UtcNow
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var service = CreateLegacyReadService(context);
|
|
var scoped = await service.GetCommentsAsync(
|
|
WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"));
|
|
|
|
Assert.Single(scoped);
|
|
Assert.Equal("Account A note", scoped.Single().Commenttext);
|
|
Assert.DoesNotContain(scoped, c => c.Commenttext == "Account B secret");
|
|
Assert.DoesNotContain(scoped, c => c.Commenttext == "Null account note");
|
|
|
|
var orgWide = await service.GetCommentsAsync(WorkOrderAccountTestHelpers.OrgWideAdmin());
|
|
Assert.Equal(3, orgWide.Count());
|
|
|
|
var missingEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
|
service.GetCommentsAsync(WorkOrderAccountTestHelpers.MissingScope()));
|
|
Assert.Equal("Forbidden", missingEx.Code);
|
|
}
|
|
|
|
private sealed class RecordingLocationDataService : ILocationDataService
|
|
{
|
|
private readonly ILocationDataService _inner;
|
|
|
|
public RecordingLocationDataService(ILocationDataService inner)
|
|
{
|
|
_inner = inner;
|
|
}
|
|
|
|
public CancellationToken LastScopeToken { get; private set; }
|
|
|
|
public Task<Locations?> GetByIdAsync(int id) => _inner.GetByIdAsync(id);
|
|
public Task<Locations?> GetByIdWithDetailsAsync(int id) => _inner.GetByIdWithDetailsAsync(id);
|
|
public Task<IEnumerable<Locations>> GetAllAsync() => _inner.GetAllAsync();
|
|
public Task<IEnumerable<Locations>> GetByAccountIdAsync(int accountId) => _inner.GetByAccountIdAsync(accountId);
|
|
public Task<(IEnumerable<Locations> Items, int TotalCount)> GetPagedAsync(int page, int pageSize, string? search = null)
|
|
=> _inner.GetPagedAsync(page, pageSize, search);
|
|
public Task<(IEnumerable<object> Items, int TotalCount)> GetAddressbookPagedAsync(int page, int pageSize, string? search = null)
|
|
=> _inner.GetAddressbookPagedAsync(page, pageSize, search);
|
|
public Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(
|
|
string? search = null,
|
|
CancellationToken cancellationToken = default)
|
|
=> _inner.GetSiteOptionsAsync(search, cancellationToken);
|
|
|
|
public Task<(bool Exists, int? AccountId)> GetAccountScopeAsync(
|
|
int locationId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
LastScopeToken = cancellationToken;
|
|
return _inner.GetAccountScopeAsync(locationId, cancellationToken);
|
|
}
|
|
|
|
public Task<Locations> AddAsync(Locations location) => _inner.AddAsync(location);
|
|
public Task UpdateAsync(Locations location) => _inner.UpdateAsync(location);
|
|
public Task DeleteAsync(int id) => _inner.DeleteAsync(id);
|
|
public Task<bool> ExistsAsync(int id) => _inner.ExistsAsync(id);
|
|
public Task<int> CountAsync() => _inner.CountAsync();
|
|
public Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(
|
|
int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken, string? sortBy = null, string? sortDirection = null)
|
|
=> _inner.GetListPagedAsync(page, pageSize, search, states, cancellationToken, sortBy, sortDirection);
|
|
public Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
|
|
=> _inner.GetDetailByIdAsync(id, cancellationToken);
|
|
public Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
|
|
=> _inner.GetByIdForUpdateAsync(id, cancellationToken);
|
|
public Task<IReadOnlyList<Contacts>> GetSiteContactsByLocationIdsAsync(
|
|
IReadOnlyCollection<int> locationIds,
|
|
CancellationToken cancellationToken)
|
|
=> _inner.GetSiteContactsByLocationIdsAsync(locationIds, cancellationToken);
|
|
public Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken)
|
|
=> _inner.AddAsync(location, cancellationToken);
|
|
public Task UpdateAsync(Locations location, CancellationToken cancellationToken)
|
|
=> _inner.UpdateAsync(location, cancellationToken);
|
|
public Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken)
|
|
=> _inner.DeleteByIdAsync(id, cancellationToken);
|
|
}
|
|
}
|