All files / server/src/features/assignment/run-assignment runAssignmentTestUtils.ts

86.79% Statements 92/106
65.38% Branches 17/26
100% Functions 25/25
86.53% Lines 90/104

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282                                                      5x   5x         284x 230x       54x 1980x     54x 54x               5x     10x   10x 10x     10x   10x 10x     10x   10x 10x     10x   10x         10x   10x           600x 284x 5372x     284x           284x 284x         284x   284x   554x             284x                     10x       10x     5x     9x   9x 238x 266x   238x     9x 9x                       5x         3x       3x     3x   82x 3x     3x 3x   840x 82x 82x     3x 3x 82x           3x   3x     5x             8x             8x   8x           5x     9x 9x 9x   9x   328x       9x 2317x       9x 328x   9x   9x 328x 45632x   328x         328x     328x             9x   328x 238x 238x     9x 372x 174x     9x 58x 174x   58x      
import { isEqual } from "date-fns";
import { groupBy } from "remeda";
import { expect } from "vitest";
import { AssignmentAlgorithm } from "shared/config/eventConfigTypes";
import { DIRECT_SIGNUP_PRIORITY } from "shared/constants/signups";
import { MongoDbError } from "shared/types/api/errors";
import { EventLogAction } from "shared/types/models/eventLog";
import { UserAssignmentResult } from "shared/types/models/result";
import { User } from "shared/types/models/user";
import {
  Result,
  makeErrorResult,
  makeSuccessResult,
} from "shared/utils/result";
import { runAssignment } from "server/features/assignment/run-assignment/runAssignment";
import { getStartingProgramItems } from "server/features/assignment/utils/getStartingProgramItems";
import { getLotteryParticipantDirectSignups } from "server/features/assignment/utils/prepareAssignmentParams";
import { findDirectSignups } from "server/features/direct-signup/directSignupRepository";
import { findProgramItems } from "server/features/program-item/programItemRepository";
import { findUsers } from "server/features/user/userRepository";
import { createLotterySignups } from "server/test/test-data-generation/generators/createLotterySignups";
import { createProgramItems } from "server/test/test-data-generation/generators/createProgramItems";
import { generateTestUsers } from "server/test/test-data-generation/generators/generateTestData";
import { unsafelyUnwrap } from "server/test/utils/unsafelyUnwrapResult";
import { AssignmentResultStatus } from "server/types/resultTypes";
import { logger } from "server/utils/logger";
 
export const firstLotterySignupSlot = 3;
 
const getGroupCreator = (
  users: User[],
  user: User,
): Result<User, MongoDbError> => {
  // Group creator (or not in a group) -> user is their own creator
  if (user.isGroupCreator || user.groupCode === "0") {
    return makeSuccessResult(user);
  }
 
  // Group member -> find the group's creator
  const groupCreator = users.find(
    (creator) => creator.isGroupCreator && creator.groupCode === user.groupCode,
  );
 
  Eif (groupCreator) {
    return makeSuccessResult(groupCreator);
  }
 
  logger.error(new Error(`Group creator not found for user ${user.username}`));
 
  return makeErrorResult(MongoDbError.UNKNOWN_ERROR);
};
 
export const verifyUserSignups = async (): Promise<
  Result<void, MongoDbError>
> => {
  logger.info("Verify lottery signups and signups match for users");
 
  const usersResult = await findUsers();
  Iif (!usersResult.ok) {
    return usersResult;
  }
  const users = usersResult.value;
 
  const signupsResult = await findDirectSignups();
  Iif (!signupsResult.ok) {
    return signupsResult;
  }
  const signups = signupsResult.value;
 
  const programItemsResult = await findProgramItems();
  Iif (!programItemsResult.ok) {
    return programItemsResult;
  }
  const programItems = programItemsResult.value;
 
  const lotteryParticipantDirectSignups = getLotteryParticipantDirectSignups(
    signups,
    programItems,
  );
 
  let mismatchFound = false;
 
  for (const {
    programItemId,
    userSignups,
  } of lotteryParticipantDirectSignups) {
    // A member's spot must match a lottery sign-up of their group creator, who for an
    // individual is the user themselves
    for (const userSignup of userSignups) {
      const matchingUser = users.find(
        (user) => user.username === userSignup.username,
      );
 
      Iif (!matchingUser) {
        logger.error(new Error(`No matching user: ${userSignup.username}`));
        mismatchFound = true;
        continue;
      }
 
      const groupCreatorResult = getGroupCreator(users, matchingUser);
      Iif (!groupCreatorResult.ok) {
        mismatchFound = true;
        continue;
      }
 
      const groupCreator = groupCreatorResult.value;
 
      const matchingCreatorLotterySignup = groupCreator.lotterySignups.some(
        (creatorLotterySignup) =>
          creatorLotterySignup.programItemId === programItemId &&
          isEqual(
            new Date(creatorLotterySignup.signedToStartTime),
            new Date(userSignup.signedToStartTime),
          ),
      );
 
      Iif (!matchingCreatorLotterySignup) {
        logger.error(
          new Error(
            `No matching signed program item found from group creator: ${userSignup.username} - ${programItemId}`,
          ),
        );
        mismatchFound = true;
      }
    }
  }
 
  Iif (mismatchFound) {
    return makeErrorResult(MongoDbError.UNKNOWN_ERROR);
  }
 
  return makeSuccessResult();
};
 
export const assertUserUpdatedCorrectly = async (
  usernames: string[],
): Promise<void> => {
  const users = unsafelyUnwrap(await findUsers(usernames));
 
  for (const user of users) {
    const newAssignmentEventLogItems = user.eventLogItems.filter(
      (eventLogItem) => eventLogItem.action === EventLogAction.NEW_ASSIGNMENT,
    );
    expect(newAssignmentEventLogItems).toHaveLength(1);
  }
 
  const verifyResult = await verifyUserSignups();
  expect(verifyResult.ok).toBe(true);
};
 
interface AssertSecondRunChangesNothingParams {
  assignmentAlgorithm: AssignmentAlgorithm;
  assignmentTime: string;
  firstRunResults: readonly UserAssignmentResult[];
}
 
// The lottery for a start time happens once, so running it again lotteries nothing and leaves
// every spot the first run handed out where it is. Nothing here depends on which algorithm ran,
// so the per-algorithm suites share it.
export const assertSecondRunChangesNothing = async ({
  assignmentAlgorithm,
  assignmentTime,
  firstRunResults,
}: AssertSecondRunChangesNothingParams): Promise<void> => {
  const secondRunResults = unsafelyUnwrap(
    await runAssignment({ assignmentAlgorithm, assignmentTime }),
  );
 
  expect(secondRunResults.status).toEqual(
    AssignmentResultStatus.ALREADY_LOTTERIED,
  );
  expect(secondRunResults.results).toHaveLength(0);
 
  const firstRunWinners = firstRunResults.map((result) => result.username);
  const firstRunWinnerSet = new Set(firstRunWinners);
 
  // Same attendee, same program item, still exactly one spot each
  const signupsAfterSecondRun = unsafelyUnwrap(await findDirectSignups());
  const heldProgramItemsByWinner = new Map(
    signupsAfterSecondRun.flatMap((signup) =>
      signup.userSignups
        .filter((userSignup) => firstRunWinnerSet.has(userSignup.username))
        .map((userSignup) => [userSignup.username, signup.programItemId]),
    ),
  );
  expect(heldProgramItemsByWinner.size).toEqual(firstRunWinnerSet.size);
  for (const result of firstRunResults) {
    expect(heldProgramItemsByWinner.get(result.username)).toEqual(
      result.assignmentSignup.programItemId,
    );
  }
 
  // Prior winners still have exactly one assignment, not a second one
  await assertUserUpdatedCorrectly(firstRunWinners);
 
  await assertAssignmentInvariants(assignmentTime);
};
 
export const generateTestData = async (
  newUsersCount: number,
  newProgramItemsCount: number,
  groupSize: number,
  numberOfGroups: number,
  testUsersCount: number,
): Promise<void> => {
  await generateTestUsers(
    newUsersCount,
    groupSize,
    numberOfGroups,
    testUsersCount,
  );
 
  await createProgramItems(newProgramItemsCount);
 
  await createLotterySignups();
};
 
// Properties any valid assignment has, whatever the fixtures or the algorithm's shuffle produced.
// The generated data is 30 attendees in 10 groups across 10 program items, which is where these
// have something to catch that a handful of hand-built attendees cannot.
export const assertAssignmentInvariants = async (
  assignmentTime: string,
): Promise<void> => {
  const programItems = unsafelyUnwrap(await findProgramItems());
  const directSignups = unsafelyUnwrap(await findDirectSignups());
  const users = unsafelyUnwrap(await findUsers());
 
  const startingProgramItemIds = new Set(
    getStartingProgramItems(programItems, assignmentTime).map(
      (programItem) => programItem.programItemId,
    ),
  );
 
  const signupsForStartTime = directSignups.filter((directSignup) =>
    startingProgramItemIds.has(directSignup.programItemId),
  );
 
  // Nobody holds two spots at one start time
  const usernamesWithSpot = signupsForStartTime.flatMap((directSignup) =>
    directSignup.userSignups.map((userSignup) => userSignup.username),
  );
  expect(usernamesWithSpot).toHaveLength(new Set(usernamesWithSpot).size);
 
  for (const directSignup of signupsForStartTime) {
    const programItem = programItems.find(
      (found) => found.programItemId === directSignup.programItemId,
    );
    Iif (!programItem) {
      continue;
    }
 
    // Attendance limits hold, and count matches the attendees it tallies
    expect(directSignup.userSignups.length).toBeLessThanOrEqual(
      programItem.maxAttendance,
    );
    expect(directSignup.count).toEqual(directSignup.userSignups.length);
  }
 
  // A group is placed as a whole or not at all, and always into one program item: members
  // attend what their creator signed the group up for. Only spots the lottery handed out
  // count - a member keeping a first-come-first-served sign-up of their own while the group
  // goes unplaced is not a split, it is just their own sign-up standing.
  const placedProgramItemByUsername = new Map(
    signupsForStartTime.flatMap((directSignup) =>
      directSignup.userSignups
        .filter((userSignup) => userSignup.priority !== DIRECT_SIGNUP_PRIORITY)
        .map((userSignup) => [userSignup.username, directSignup.programItemId]),
    ),
  );
  const groupedUsers = groupBy(
    users.filter((user) => user.groupCode !== "0"),
    (user) => user.groupCode,
  );
 
  for (const groupMembers of Object.values(groupedUsers)) {
    const placements = groupMembers.map((groupMember) =>
      placedProgramItemByUsername.get(groupMember.username),
    );
    expect(new Set(placements).size).toEqual(1);
  }
};