1
0
Fork 0
polaris-task-force/tests/int/mission-lifecycle.int.spec.ts
Z8MB1E 74564afb2b feat(missions): auto-start and auto-complete ops, plus side-op scheduling
- The mission tick now runs two idempotent sweeps: auto-start flips Scheduled
  missions whose start time has arrived to Active, and auto-complete sets
  missions whose scheduled window (start plus duration) has fully passed to
  Completed with a stamped completedAt.
- Side ops are created as quiet Ready drafts and committed with a "Mark as
  Scheduled" control, which is what posts the Discord roll-call and hands the
  op to the lifecycle above.
- Daily schedule reminders nudge the creator to set a draft live (web
  notification plus Discord DM).
- Adds the mission lifecycle/commit event types and the matching notification
  labels, and updates the bot docs.
2026-10-03 03:29:35 -04:00

353 lines
13 KiB
TypeScript

import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Campaign, Map as MissionMap, Mission, User } from "@/payload-types";
import {
DEFAULT_MISSION_DURATION_MINUTES,
autoCompleteMissions,
autoStartMissions,
hasMissionStarted,
isMissionWindowComplete,
missionEndTime,
} from "@/lib/intelligence/missionLifecycle";
let payload: Payload;
const RUN = `mlc-${Date.now().toString(36)}`;
describe("Mission lifecycle (auto-start + auto-complete)", () => {
let mapId: number;
let campaignId: number;
let authorId: number;
const missionIds: number[] = [];
// Ids completed by the window sweep, so the idempotency check can scope to
// exactly those (the suite deliberately leaves other due missions behind).
const completedBySweep: number[] = [];
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
const map = (await payload.create({
collection: "maps",
data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
overrideAccess: true,
depth: 0,
})) as unknown as MissionMap;
mapId = map.id;
const campaign = (await payload.create({
collection: "campaigns",
data: { name: `${RUN} Campaign`, summary: "Auto-complete test campaign", status: "concept", campaignMode: "custom" },
overrideAccess: true,
depth: 0,
})) as unknown as Campaign;
campaignId = campaign.id;
const author = (await payload.create({
collection: "users",
data: {
username: `${RUN}-author`,
discordUsername: `${RUN}-author`,
displayName: "AUTHOR",
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles: ["user"],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
authorId = author.id;
});
afterAll(async () => {
for (const id of missionIds) {
await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {});
}
await payload
.delete({ collection: "campaigns", id: campaignId, overrideAccess: true })
.catch(() => {});
await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {});
// User deletion trips FK constraints unless the hook-provisioned
// personal bank account and profile are removed first.
const accounts = await payload
.find({
collection: "bank-accounts",
where: { ownerUser: { equals: authorId } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const account of accounts?.docs ?? []) {
await payload
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
.catch(() => {});
}
const profiles = await payload
.find({
collection: "profiles",
where: { user: { equals: authorId } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const profile of profiles?.docs ?? []) {
await payload
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
.catch(() => {});
}
await payload
.delete({ collection: "users", id: authorId, overrideAccess: true })
.catch(() => {});
});
const makeMission = async (
label: string,
status: Mission["ownershipAndStatus"]["status"],
start: Date,
estimatedDuration = 60,
): Promise<Mission> => {
const mission = (await payload.create({
collection: "missions",
data: {
name: `${RUN} ${label}`,
codeName: `${RUN}-${label}`,
summary: "Mission lifecycle boundary test mission",
operationType: "main",
classification: {
map: mapId,
missionType: "PvE",
campaign: campaignId,
startDateTime: start.toISOString(),
estimatedDuration,
},
ownershipAndStatus: {
authors: [authorId],
status,
visibility: "unit",
},
missionRoles: {
maxPlayers: 16,
},
gameDetails: {
serverDetails: {
serverIp: "127.0.0.1",
serverPort: 2302,
},
},
briefing: [],
},
overrideAccess: true,
depth: 0,
})) as unknown as Mission;
missionIds.push(mission.id);
return mission;
};
const fetchMission = (id: number): Promise<Mission> =>
payload.findByID({
collection: "missions",
id,
depth: 0,
overrideAccess: true,
}) as unknown as Promise<Mission>;
it("auto-starts only Scheduled missions whose start has arrived and whose window is still open", async () => {
const now = new Date();
now.setHours(12, 0, 0, 0);
const at = (hourOffset: number): Date => {
const d = new Date(now);
d.setHours(now.getHours() + hourOffset);
return d;
};
const inWindow = await makeMission("start-in-window", "Scheduled", at(-1), 240);
const future = await makeMission("start-future", "Scheduled", at(1), 240);
const elapsed = await makeMission("start-elapsed", "Scheduled", at(-5), 60);
const alreadyActive = await makeMission("start-already-active", "Active", at(-1), 240);
const cancelled = await makeMission("start-cancelled", "Cancelled", at(-1), 240);
const ids = [inWindow.id, future.id, elapsed.id, alreadyActive.id, cancelled.id];
const result = await autoStartMissions(payload, now, { missionIds: ids });
expect(result.started).toBe(1);
expect(result.missions.map((m) => m.id)).toEqual([inWindow.id]);
expect((await fetchMission(inWindow.id)).ownershipAndStatus.status).toBe("Active");
expect((await fetchMission(future.id)).ownershipAndStatus.status).toBe("Scheduled");
// Fully elapsed windows are auto-complete's job, not a spurious start.
expect((await fetchMission(elapsed.id)).ownershipAndStatus.status).toBe("Scheduled");
expect((await fetchMission(alreadyActive.id)).ownershipAndStatus.status).toBe("Active");
expect((await fetchMission(cancelled.id)).ownershipAndStatus.status).toBe("Cancelled");
// The partial group update must not wipe sibling fields.
const updated = await fetchMission(inWindow.id);
expect(updated.ownershipAndStatus.visibility).toBe("unit");
const authorRefs = Array.isArray(updated.ownershipAndStatus.authors)
? updated.ownershipAndStatus.authors
: [updated.ownershipAndStatus.authors];
expect(authorRefs).toContain(authorId);
});
it("is idempotent — a second auto-start sweep finds nothing left to start", async () => {
const now = new Date();
now.setHours(12, 0, 0, 0);
const result = await autoStartMissions(payload, now, { missionIds });
expect(result.started).toBe(0);
expect(result.missions).toEqual([]);
});
it("auto-completes Scheduled and Active missions once start + estimatedDuration has passed", async () => {
const now = new Date();
now.setHours(12, 0, 0, 0);
const at = (hourOffset: number): Date => {
const d = new Date(now);
d.setHours(now.getHours() + hourOffset);
return d;
};
const fromScheduled = await makeMission("done-from-scheduled", "Scheduled", at(-6), 240);
const fromActive = await makeMission("done-from-active", "Active", at(-5), 60);
const inWindow = await makeMission("done-in-window", "Active", at(-1), 240);
const readyPast = await makeMission("done-ready", "Ready", at(-5), 60);
const cancelledPast = await makeMission("done-cancelled", "Cancelled", at(-5), 60);
const ids = [fromScheduled.id, fromActive.id, inWindow.id, readyPast.id, cancelledPast.id];
const result = await autoCompleteMissions(payload, now, { missionIds: ids });
expect(result.completed).toBe(2);
expect(result.missions.map((m) => m.id).sort()).toEqual([fromScheduled.id, fromActive.id].sort());
completedBySweep.push(...result.missions.map((m) => m.id));
expect((await fetchMission(fromScheduled.id)).ownershipAndStatus.status).toBe("Completed");
expect((await fetchMission(fromActive.id)).ownershipAndStatus.status).toBe("Completed");
expect((await fetchMission(inWindow.id)).ownershipAndStatus.status).toBe("Active");
// Draft and terminal statuses are never auto-completed.
expect((await fetchMission(readyPast.id)).ownershipAndStatus.status).toBe("Ready");
expect((await fetchMission(cancelledPast.id)).ownershipAndStatus.status).toBe("Cancelled");
});
it("is idempotent — a second auto-complete sweep finds nothing left to complete", async () => {
const now = new Date();
now.setHours(12, 0, 0, 0);
const result = await autoCompleteMissions(payload, now, { missionIds: completedBySweep });
expect(result.completed).toBe(0);
expect(result.missions).toEqual([]);
});
it("stamps the exact sweep time as completedAt when auto-completing", async () => {
const now = new Date();
now.setHours(15, 0, 0, 0);
const start = new Date(now);
start.setDate(now.getDate() - 2);
start.setHours(20, 0, 0, 0);
const due = await makeMission("timestamp-due", "Scheduled", start, 60);
const result = await autoCompleteMissions(payload, now, { missionIds: [due.id] });
expect(result.completed).toBe(1);
const updated = await fetchMission(due.id);
expect(updated.ownershipAndStatus.status).toBe("Completed");
expect(updated.ownershipAndStatus.completedAt).toBe(now.toISOString());
});
it("stamps completedAt once on manual transition to Completed and never overwrites it", async () => {
const start = new Date();
// Future start date — neither sweep must ever touch this mission.
start.setDate(start.getDate() + 1);
start.setHours(20, 0, 0, 0);
const mission = await makeMission("manual-complete", "Scheduled", start, 60);
expect(mission.ownershipAndStatus.completedAt).toBeNull();
// Non-terminal transitions must not stamp a completion time.
await payload.update({
collection: "missions",
id: mission.id,
data: { ownershipAndStatus: { status: "Active" } },
overrideAccess: true,
});
expect((await fetchMission(mission.id)).ownershipAndStatus.completedAt).toBeNull();
const before = Date.now();
await payload.update({
collection: "missions",
id: mission.id,
data: { ownershipAndStatus: { status: "Completed" } },
overrideAccess: true,
});
const after = Date.now();
const completed = await fetchMission(mission.id);
expect(completed.ownershipAndStatus.completedAt).toBeTruthy();
// timestamptz(3) truncates sub-millisecond precision, hence the 1ms tolerance.
const stamped = new Date(completed.ownershipAndStatus.completedAt!).getTime();
expect(stamped).toBeGreaterThanOrEqual(before - 1);
expect(stamped).toBeLessThanOrEqual(after);
// Cancelling and re-completing keeps the original stamp.
await payload.update({
collection: "missions",
id: mission.id,
data: { ownershipAndStatus: { status: "Cancelled" } },
overrideAccess: true,
});
expect((await fetchMission(mission.id)).ownershipAndStatus.completedAt).toBe(
completed.ownershipAndStatus.completedAt,
);
await payload.update({
collection: "missions",
id: mission.id,
data: { ownershipAndStatus: { status: "Completed" } },
overrideAccess: true,
});
const recompleted = await fetchMission(mission.id);
expect(recompleted.ownershipAndStatus.completedAt).toBe(
completed.ownershipAndStatus.completedAt,
);
});
it("classifies start and window boundaries deterministically", () => {
// missionEndTime adds the duration in minutes to the start.
expect(missionEndTime("2026-08-24T20:00:00.000Z", 240)?.toISOString()).toBe(
"2026-08-25T00:00:00.000Z",
);
// Missing / invalid durations fall back to the default.
expect(missionEndTime("2026-08-24T20:00:00.000Z", null)?.toISOString()).toBe(
new Date(
new Date("2026-08-24T20:00:00.000Z").getTime() +
DEFAULT_MISSION_DURATION_MINUTES * 60_000,
).toISOString(),
);
expect(missionEndTime("not-a-date", 60)).toBeNull();
const now = new Date("2026-08-24T20:00:00.000Z");
expect(hasMissionStarted("2026-08-24T19:59:59.000Z", now)).toBe(true);
expect(hasMissionStarted("2026-08-24T20:00:00.000Z", now)).toBe(true);
expect(hasMissionStarted("2026-08-24T20:00:01.000Z", now)).toBe(false);
expect(hasMissionStarted("not-a-date", now)).toBe(false);
expect(isMissionWindowComplete("2026-08-24T20:00:00.000Z", 240, now)).toBe(false);
expect(
isMissionWindowComplete(
"2026-08-24T20:00:00.000Z",
240,
new Date("2026-08-25T00:00:00.000Z"),
),
).toBe(true);
expect(
isMissionWindowComplete(
"2026-08-24T20:00:00.000Z",
240,
new Date("2026-08-24T23:59:59.999Z"),
),
).toBe(false);
expect(isMissionWindowComplete("not-a-date", 240, now)).toBe(false);
});
});