381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import type { SchedulingRequest } from "../src/api/scheduling.ts";
|
|
import {
|
|
applySchedulingAvailabilityChoice,
|
|
groupSchedulingRequests,
|
|
participantDraftFromResponse,
|
|
participantDraftsFromPicker,
|
|
participantPayload,
|
|
schedulingInvitationActionBlocks,
|
|
schedulingPublicInvitationUrl,
|
|
schedulingSortPhase,
|
|
type SchedulingActor
|
|
} from "../src/features/scheduling/schedulingViewModel.ts";
|
|
|
|
test("maps visible account and contact selections into bounded scheduling participant payloads", () => {
|
|
let sequence = 0;
|
|
const selected = participantDraftsFromPicker([
|
|
{
|
|
selection_key: "account:account-2",
|
|
kind: "account",
|
|
reference_id: "account-2",
|
|
display_name: "Ada Account",
|
|
email: "ADA@EXAMPLE.TEST",
|
|
source_module: "access",
|
|
source_label: "Accounts",
|
|
provenance: { tenant_id: "must-not-be-persisted" },
|
|
metadata: { group_ids: ["must-not-be-persisted"] }
|
|
},
|
|
{
|
|
selection_key: "contact:contact-3:contact@example.test",
|
|
kind: "contact",
|
|
reference_id: "contact-3",
|
|
display_name: "Contact Person",
|
|
email: "contact@example.test",
|
|
source_module: "addresses",
|
|
source_label: "Contacts",
|
|
source_revision: "revision-3",
|
|
provenance: { address_book_id: "must-not-be-persisted" }
|
|
}
|
|
], [], () => `participant-${++sequence}`);
|
|
|
|
assert.equal(selected[0].respondent_id, "account-2");
|
|
assert.equal(selected[0].participant_type, "internal");
|
|
assert.equal(selected[0].email, "ada@example.test");
|
|
assert.deepEqual(selected[0].metadata, {
|
|
directory_selection: {
|
|
selection_key: "account:account-2",
|
|
kind: "account",
|
|
reference_id: "account-2",
|
|
source_module: "access",
|
|
source_label: "Accounts",
|
|
source_revision: null
|
|
}
|
|
});
|
|
assert.equal(selected[1].respondent_id, null);
|
|
assert.equal(selected[1].participant_type, "external");
|
|
assert.equal(
|
|
(selected[1].metadata?.directory_selection as { source_revision: string }).source_revision,
|
|
"revision-3"
|
|
);
|
|
assert.deepEqual(participantPayload(selected[1]), {
|
|
respondent_id: null,
|
|
display_name: "Contact Person",
|
|
email: "contact@example.test",
|
|
participant_type: "external",
|
|
required: true,
|
|
metadata: selected[1].metadata
|
|
});
|
|
});
|
|
|
|
test("reconstructs saved directory selections and preserves existing reconciliation identity", () => {
|
|
const responseParticipant = {
|
|
id: "stored-participant",
|
|
revision: "a".repeat(64),
|
|
is_current_participant: false,
|
|
respondent_id: "account-2",
|
|
display_name: "Ada Account",
|
|
email: "ada@example.test",
|
|
participant_type: "internal",
|
|
required: true,
|
|
status: "invited",
|
|
poll_invitation_id: "invitation-1",
|
|
metadata: {
|
|
directory_selection: {
|
|
selection_key: "account:account-2",
|
|
kind: "account",
|
|
reference_id: "account-2",
|
|
source_module: "access",
|
|
source_label: "Accounts"
|
|
}
|
|
}
|
|
};
|
|
const draft = participantDraftFromResponse(responseParticipant, "draft-1");
|
|
const remapped = participantDraftsFromPicker([draft], [draft], () => "unexpected");
|
|
|
|
assert.equal(remapped[0], draft);
|
|
assert.equal(draft.sourceId, "stored-participant");
|
|
assert.equal(draft.identityLocked, true);
|
|
assert.equal(participantPayload(draft).id, "stored-participant");
|
|
assert.equal(participantPayload(draft).revision, "a".repeat(64));
|
|
});
|
|
|
|
const now = new Date("2026-07-20T10:00:00Z");
|
|
const actor: SchedulingActor = {
|
|
accountId: "account-1",
|
|
membershipId: "membership-1",
|
|
email: "person@example.test"
|
|
};
|
|
|
|
function request(
|
|
id: string,
|
|
options: {
|
|
organizer?: string;
|
|
participantStatus?: string;
|
|
status?: SchedulingRequest["status"];
|
|
start?: string;
|
|
end?: string;
|
|
} = {}
|
|
): SchedulingRequest {
|
|
return {
|
|
id,
|
|
tenant_id: "tenant-1",
|
|
title: id,
|
|
timezone: "UTC",
|
|
status: options.status ?? "collecting",
|
|
organizer_user_id: options.organizer ?? "organizer-elsewhere",
|
|
allow_external_participants: true,
|
|
allow_participant_updates: true,
|
|
result_visibility: "after_close",
|
|
participant_visibility: "aggregates_only",
|
|
effective_participant_visibility: "aggregates_only",
|
|
participant_aggregate: {
|
|
total: options.participantStatus ? 1 : 0,
|
|
status_counts: options.participantStatus ? { [options.participantStatus]: 1 } : {}
|
|
},
|
|
participant_visibility_decision: {
|
|
requested_visibility: "aggregates_only",
|
|
effective_visibility: "aggregates_only",
|
|
policy_applied: false,
|
|
source_path: [],
|
|
details: {}
|
|
},
|
|
notify_on_answers: true,
|
|
single_choice: false,
|
|
max_participants_per_option: null,
|
|
allow_maybe: true,
|
|
allow_comments: false,
|
|
participant_email_required: false,
|
|
anonymous_password_protection_enabled: false,
|
|
public_participation_policy_enforcement_available: false,
|
|
public_participation_policy_enforcement_reason: "Public participation gateway not installed",
|
|
participant_invitation_delivery_available: false,
|
|
calendar_integration_enabled: false,
|
|
calendar_freebusy_enabled: false,
|
|
calendar_hold_enabled: false,
|
|
create_calendar_event_on_decision: false,
|
|
created_at: "2026-07-01T00:00:00Z",
|
|
updated_at: "2026-07-01T00:00:00Z",
|
|
metadata: {},
|
|
slots: [{
|
|
id: `slot-${id}`,
|
|
label: id,
|
|
start_at: options.start ?? "2026-07-21T09:00:00Z",
|
|
end_at: options.end ?? "2026-07-21T10:00:00Z",
|
|
timezone: "UTC",
|
|
position: 0,
|
|
revision: "0".repeat(64),
|
|
freebusy_conflicts: [],
|
|
metadata: {}
|
|
}],
|
|
participants: options.participantStatus ? [{
|
|
id: `participant-${id}`,
|
|
is_current_participant: true,
|
|
respondent_id: "membership-1",
|
|
email: "person@example.test",
|
|
participant_type: "internal",
|
|
required: true,
|
|
status: options.participantStatus,
|
|
metadata: {}
|
|
}] : []
|
|
};
|
|
}
|
|
|
|
test("groups owned, invited, and administrator-only requests without hiding any", () => {
|
|
const groups = groupSchedulingRequests([
|
|
request("managed"),
|
|
request("mine", { organizer: "account-1" }),
|
|
request("invited", { participantStatus: "invited" })
|
|
], actor, now);
|
|
|
|
assert.deepEqual(groups.owned.map((item) => item.id), ["mine"]);
|
|
assert.deepEqual(groups.invited.map((item) => item.id), ["invited"]);
|
|
assert.deepEqual(groups.other.map((item) => item.id), ["managed"]);
|
|
});
|
|
|
|
test("matches email-only invitations without case sensitivity", () => {
|
|
const invited = request("email-case", { participantStatus: "invited" });
|
|
invited.participants[0].is_current_participant = false;
|
|
invited.participants[0].respondent_id = null;
|
|
invited.participants[0].email = "Person@Example.Test";
|
|
|
|
const groups = groupSchedulingRequests([invited], actor, now);
|
|
|
|
assert.deepEqual(groups.invited.map((item) => item.id), ["email-case"]);
|
|
assert.deepEqual(groups.other, []);
|
|
});
|
|
|
|
test("recognizes the current participant after identity bindings are redacted", () => {
|
|
const invited = request("safe-projection", { participantStatus: "invited" });
|
|
invited.participants[0].respondent_id = null;
|
|
invited.participants[0].email = null;
|
|
|
|
const groups = groupSchedulingRequests([invited], actor, now);
|
|
|
|
assert.deepEqual(groups.invited.map((item) => item.id), ["safe-projection"]);
|
|
assert.deepEqual(groups.other, []);
|
|
});
|
|
|
|
test("keeps an organizer's active request in the open phase even if they also responded", () => {
|
|
const owned = request("mine-and-invited", {
|
|
organizer: "account-1",
|
|
participantStatus: "responded"
|
|
});
|
|
|
|
assert.equal(schedulingSortPhase(owned, actor, now), "unanswered");
|
|
});
|
|
|
|
test("orders unanswered by nearest slot before answered, closed, determined, and past", () => {
|
|
const groups = groupSchedulingRequests([
|
|
request("past", {
|
|
participantStatus: "invited",
|
|
start: "2026-07-18T09:00:00Z",
|
|
end: "2026-07-18T10:00:00Z"
|
|
}),
|
|
request("determined", { participantStatus: "invited", status: "decided" }),
|
|
request("closed", { participantStatus: "invited", status: "closed" }),
|
|
request("answered", { participantStatus: "responded" }),
|
|
request("later", { participantStatus: "invited", start: "2026-07-23T09:00:00Z" }),
|
|
request("nearer", { participantStatus: "invited", start: "2026-07-21T09:00:00Z" })
|
|
], actor, now);
|
|
|
|
assert.deepEqual(groups.invited.map((item) => item.id), [
|
|
"nearer",
|
|
"later",
|
|
"answered",
|
|
"closed",
|
|
"determined",
|
|
"past"
|
|
]);
|
|
assert.equal(schedulingSortPhase(groups.invited[0], actor, now), "unanswered");
|
|
assert.equal(schedulingSortPhase(groups.invited.at(-1)!, actor, now), "past");
|
|
});
|
|
|
|
test("derives stable invitation action blocks from policy, delivery, and participant state", () => {
|
|
const managed = request("invitation-actions", { participantStatus: "invited" });
|
|
managed.poll_id = "poll-1";
|
|
managed.public_participation_policy_enforcement_available = true;
|
|
managed.public_participation_policy_enforcement_reason = null;
|
|
managed.participant_invitation_delivery_available = true;
|
|
const participant = managed.participants[0];
|
|
assert.deepEqual(schedulingInvitationActionBlocks(managed, participant, now), {
|
|
copy: "participant_revision_unavailable",
|
|
send: "participant_revision_unavailable",
|
|
revoke: "participant_revision_unavailable"
|
|
});
|
|
participant.revision = "a".repeat(64);
|
|
participant.poll_invitation_id = "invitation-1";
|
|
|
|
assert.deepEqual(schedulingInvitationActionBlocks(managed, participant, now), {
|
|
copy: null,
|
|
send: null,
|
|
revoke: null
|
|
});
|
|
|
|
managed.participant_invitation_delivery_available = false;
|
|
assert.equal(
|
|
schedulingInvitationActionBlocks(managed, participant, now).send,
|
|
"delivery_unavailable"
|
|
);
|
|
|
|
managed.participant_invitation_delivery_available = true;
|
|
participant.email = null;
|
|
participant.respondent_id = `scheduling-participant:${participant.id}`;
|
|
assert.equal(
|
|
schedulingInvitationActionBlocks(managed, participant, now).send,
|
|
"no_delivery_target"
|
|
);
|
|
|
|
managed.public_participation_policy_enforcement_available = false;
|
|
assert.deepEqual(schedulingInvitationActionBlocks(managed, participant, now), {
|
|
copy: "participation_policy_unavailable",
|
|
send: "participation_policy_unavailable",
|
|
revoke: "participation_policy_unavailable"
|
|
});
|
|
|
|
participant.poll_invitation_id = null;
|
|
assert.equal(
|
|
schedulingInvitationActionBlocks(managed, participant, now).revoke,
|
|
"no_active_invitation"
|
|
);
|
|
});
|
|
|
|
test("allows bounded cancelled-request links until the notice expires without blocking revocation", () => {
|
|
const cancelled = request("cancelled-invitation", {
|
|
participantStatus: "invited",
|
|
status: "cancelled"
|
|
});
|
|
cancelled.poll_id = "poll-1";
|
|
cancelled.public_participation_policy_enforcement_available = true;
|
|
cancelled.participant_invitation_delivery_available = true;
|
|
cancelled.cancellation_notice_until = "2026-07-20T11:00:00Z";
|
|
cancelled.participants[0].revision = "b".repeat(64);
|
|
cancelled.participants[0].poll_invitation_id = "invitation-1";
|
|
|
|
assert.deepEqual(schedulingInvitationActionBlocks(cancelled, cancelled.participants[0], now), {
|
|
copy: null,
|
|
send: null,
|
|
revoke: null
|
|
});
|
|
|
|
cancelled.cancellation_notice_until = "2026-07-20T09:00:00Z";
|
|
assert.deepEqual(schedulingInvitationActionBlocks(cancelled, cancelled.participants[0], now), {
|
|
copy: "cancellation_notice_expired",
|
|
send: "cancellation_notice_expired",
|
|
revoke: null
|
|
});
|
|
});
|
|
|
|
test("accepts only same-origin Scheduling public invitation URLs", () => {
|
|
assert.equal(
|
|
schedulingPublicInvitationUrl(
|
|
"/scheduling/public/request-1/token-1",
|
|
"https://govoplan.example"
|
|
),
|
|
"https://govoplan.example/scheduling/public/request-1/token-1"
|
|
);
|
|
assert.equal(
|
|
schedulingPublicInvitationUrl(
|
|
"https://attacker.example/scheduling/public/request-1/token-1",
|
|
"https://govoplan.example"
|
|
),
|
|
null
|
|
);
|
|
assert.equal(
|
|
schedulingPublicInvitationUrl(
|
|
"/scheduling/publicity/request-1/token-1",
|
|
"https://govoplan.example"
|
|
),
|
|
null
|
|
);
|
|
});
|
|
|
|
test("single-choice availability keeps one positive choice while preserving explicit no answers", () => {
|
|
const next = applySchedulingAvailabilityChoice(
|
|
["slot-a", "slot-b", "slot-c"],
|
|
{ "slot-a": "available", "slot-b": "maybe", "slot-c": "unavailable" },
|
|
"slot-b",
|
|
"available",
|
|
true
|
|
);
|
|
|
|
assert.deepEqual(next, {
|
|
"slot-a": "unavailable",
|
|
"slot-b": "available",
|
|
"slot-c": "unavailable"
|
|
});
|
|
});
|
|
|
|
test("multi-choice availability changes only the selected slot", () => {
|
|
const next = applySchedulingAvailabilityChoice(
|
|
["slot-a", "slot-b"],
|
|
{ "slot-a": "available" },
|
|
"slot-b",
|
|
"maybe",
|
|
false
|
|
);
|
|
|
|
assert.deepEqual(next, { "slot-a": "available", "slot-b": "maybe" });
|
|
});
|