From ebf149f3c7ee60234132b42bed42c33cf206a6b7 Mon Sep 17 00:00:00 2001 From: Wallace Date: Tue, 14 Jul 2026 13:54:28 +0100 Subject: [PATCH 1/2] fix(cli): copy invited usernames via a native clipboard tool The confirm-flow and `--report` copy wrote only an OSC 52 terminal escape, which GNOME Terminal / VTE silently ignore, so the clipboard stayed empty while "Copied N usernames." printed unconditionally (OSC 52 has no acknowledgement to check). Prefer a native clipboard tool selected by environment (wl-copy on Wayland, xclip on X11, pbcopy on macOS) whose forked daemon keeps owning the selection after this short-lived CLI exits; a JVM/AWT clipboard would lose the contents the moment we exit. OSC 52 is kept only as a remote/SSH fallback, and the "Copied" line now prints only when the tool exits 0; otherwise the usernames (already listed above) plus a hint are shown. Co-authored-by: Claude Opus 4.8 (1M context) --- src/main/scala/ccas/cli/Dispatcher.scala | 69 ++++++++++++++++++++---- 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/src/main/scala/ccas/cli/Dispatcher.scala b/src/main/scala/ccas/cli/Dispatcher.scala index 21d02ab..2bfb216 100644 --- a/src/main/scala/ccas/cli/Dispatcher.scala +++ b/src/main/scala/ccas/cli/Dispatcher.scala @@ -330,20 +330,69 @@ object Dispatcher { "they are listed in the job log above." ).orDie - // OSC 52 clipboard write: `ESC ] 52 ; c ; BEL`. The terminal itself owns the clipboard, so this - // works over SSH (tmux needs `set -g set-clipboard on`). Payloads are a handful of usernames, far under any - // terminal's OSC 52 size cap. ESC/BEL as \u escapes per the repo's no-raw-control-bytes convention. + // Copy the usernames to the system clipboard. A native clipboard tool is preferred: its forked daemon keeps owning + // the selection after this short-lived CLI exits, whereas an in-process JVM clipboard (AWT) would lose the contents + // the moment we exit. The OSC 52 terminal escape is only a fallback for a remote/SSH session whose *local* terminal + // implements it — many terminals (notably GNOME Terminal / VTE) silently ignore OSC 52 clipboard writes, which is + // why the old escape-only path copied nothing. The "Copied" line prints only when the copy actually succeeds. private def copyToClipboard(names: List[String]): Task[Unit] = if (names.isEmpty) { Console.printLineError("no invited usernames to copy").orDie } - // Callers only reach here on a TTY, but guard defensively: an OSC 52 escape to a non-terminal would corrupt the - // stream and no terminal is there to honour it, so fall back to just printing the usernames. - else if (!hasTty) { - Console.printLineError("clipboard unavailable (not an interactive terminal); printing usernames instead").orDie *> - printBare(names) - } else { + else { + clipboardCommand match { + case Some(cmd) => + copyViaTool(cmd, names).flatMap { copied => + if (copied) { Console.printLine(s"Copied ${names.size} usernames.").orDie } + else { copyFallback(names) } + } + case None => copyFallback(names) + } + } + + // The native clipboard command for this environment, if any: macOS -> pbcopy; Wayland -> wl-copy; X11 -> xclip. + // None on a headless / unknown session, which routes to the OSC 52 fallback. + private def clipboardCommand: Option[List[String]] = + if (isMac) { Some(List("pbcopy")) } + else if (envSet("WAYLAND_DISPLAY")) { Some(List("wl-copy")) } + else if (envSet("DISPLAY")) { Some(List("xclip", "-selection", "clipboard")) } + else { None } + + private def isMac: Boolean = sys.props.getOrElse("os.name", "").toLowerCase.contains("mac") + private def envSet(name: String): Boolean = Option(java.lang.System.getenv(name)).exists(_.nonEmpty) + + // Feed the newline-joined usernames to the clipboard tool's stdin, then wait. Returns true only if the process + // launched and exited 0; a launch failure (tool not installed -> IOException) or non-zero exit yields false so the + // caller falls back. Closing stdin sends EOF, which wl-copy / xclip / pbcopy wait for before taking ownership. + private def copyViaTool(cmd: List[String], names: List[String]): UIO[Boolean] = + ZIO.attemptBlocking { + val pb = new ProcessBuilder(cmd*) + // Discard the tool's stdout/stderr rather than leaving an un-drained pipe that could wedge waitFor if the tool + // ever got chatty (wl-copy / xclip / pbcopy are silent on success, but this is robust regardless). + pb.redirectOutput(ProcessBuilder.Redirect.DISCARD) + pb.redirectError(ProcessBuilder.Redirect.DISCARD) + val process = pb.start() + val stdin = process.getOutputStream + try { stdin.write(names.mkString("\n").getBytes(StandardCharsets.UTF_8)) } + finally { stdin.close() } + process.waitFor() == 0 + }.orElseSucceed(false) + + // No working native clipboard tool. The usernames were already printed by the caller (renderReport / confirmPrompt), + // so don't reprint them — on a TTY emit the OSC 52 escape as a best-effort copy (works only if the terminal honours + // it, e.g. an SSH session into kitty / tmux with `set -g set-clipboard on`) and note the caveat on stderr. ESC/BEL as + // \u escapes per the repo's no-raw-control-bytes convention. + private def copyFallback(names: List[String]): Task[Unit] = + if (hasTty) { val b64 = Base64.getEncoder.encodeToString(names.mkString("\n").getBytes(StandardCharsets.UTF_8)) val osc52 = "\u001b]52;c;" + b64 + "\u0007" - Console.print(osc52).orDie *> Console.printLine(s"Copied ${names.size} usernames.").orDie + Console.print(osc52).orDie *> + Console.printLineError( + "no clipboard tool found (install wl-copy or xclip); sent an OSC 52 copy escape as a fallback — it only " + + "works if your terminal supports it. The usernames are listed above." + ).orDie + } else { + Console.printLineError( + "clipboard unavailable (no clipboard tool and not an interactive terminal); the usernames are listed above." + ).orDie } private def parseMisfire(s: String): IO[CliError, MisfirePolicy] = From 9c8f2eec86ae5cb3add2362a7dad3a62b9906b39 Mon Sep 17 00:00:00 2001 From: Wallace Date: Tue, 14 Jul 2026 13:54:37 +0100 Subject: [PATCH 2/2] fix(recruitment): cap deferred-confirm at target; keep excess Deferred MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chunk overshoot (the last explore batch runs fully, no mid-chunk stop) persists more passing candidates as Deferred than the run's target. The interactive confirm (confirmDeferredByRun) flipped ALL of them to Invited, uncapped, so `ccas recruit --report` showed e.g. 31 invited for a target of 30 — one more than intended. Match the auto-confirm path, which already invites only the target set and leaves the excess Deferred to carry to the next run (selectDeferredBy Club). The run now stores its effective target, and both the /found prompt and the confirm flip cap at `target - already-invited-this-run` (lowest player_ids first). That keeps the confirm idempotent — a re-POST flips 0, per the endpoint contract — and carries the overshoot forward rather than deleting it. Also align the report/clipboard username list with the file output: drop player_ids that don't resolve to a handle (paste-ready invite lists carry only invitable usernames), matching the server's usernamesFor, and log the resolved count in showReport. Schema: recruitment_run gains a nullable `target` column (CREATE TABLE is canonical; legacy NULL rows fall through to no cap). Apply `ALTER TABLE recruitment_run ADD COLUMN IF NOT EXISTS target INT` to any existing database (incl. prod) before deploying this. Co-authored-by: Claude Opus 4.8 (1M context) --- .../apps/recruitment/RecruitmentApp.scala | 21 ++++--- .../apps/recruitment/RecruitmentExplore.scala | 6 +- .../tables/RecruitmentCandidate.scala | 36 ++++++++--- .../ccas/analysis/tables/RecruitmentRun.scala | 12 +++- .../recruitment/TestRecruitmentAppCore.scala | 46 +++++++------- .../TestRecruitmentAppExplore.scala | 63 ++++++++++++++++--- .../TestRecruitmentAppNetworkOutage.scala | 2 +- .../TestRecruitmentBlacklist.scala | 4 +- .../TestRecruitmentEvaluation.scala | 10 +-- .../TestRecruitmentFilterChain.scala | 28 ++++----- .../scala/ccas/server/routes/TestRoutes.scala | 2 +- 11 files changed, 156 insertions(+), 74 deletions(-) diff --git a/src/main/scala/ccas/analysis/apps/recruitment/RecruitmentApp.scala b/src/main/scala/ccas/analysis/apps/recruitment/RecruitmentApp.scala index 7f73a9f..9623c88 100644 --- a/src/main/scala/ccas/analysis/apps/recruitment/RecruitmentApp.scala +++ b/src/main/scala/ccas/analysis/apps/recruitment/RecruitmentApp.scala @@ -95,7 +95,10 @@ object RecruitmentApp extends ZIOAppDefault { if (parsed.cumulative) RecruitmentCandidate.selectInvitedToday(run.clubId, parsed.alias) else RecruitmentCandidate.selectInvitedByRun(run.runId) resolvedMap <- Player.resolveUsernames(candidates.map(_.playerId)) - usernames = candidates.map(c => resolvedMap.getOrElse(c.playerId, Username.wrap(s"[pid=${c.playerId}]"))) + // Drop any player_id that doesn't resolve to a handle — these lists are paste-ready invite targets, and a + // `[pid=N]` placeholder isn't invitable. Matches the server's `usernamesFor` so the file and the + // --report/clipboard output are identical. + usernames = candidates.flatMap(c => resolvedMap.get(c.playerId)) evaluatedCount <- RecruitmentCandidate.selectCountByRun(run.runId) now <- Clock.instant output = formatRecruitmentOutput( @@ -162,7 +165,7 @@ object RecruitmentApp extends ZIOAppDefault { else ZIO.succeed(0) effectiveTarget = (resolvedTarget - alreadyFound) max 0 now <- Clock.instant - runId <- RecruitmentRun.insert(clubId, criteria.criteriaId, trigger, now, jobRunId) + runId <- RecruitmentRun.insert(clubId, criteria.criteriaId, trigger, now, Some(effectiveTarget), jobRunId) // --- Shared setup --- targetMembers <- ApiClubMembers.get(client, effectiveSlug) @@ -401,6 +404,7 @@ object RecruitmentApp extends ZIOAppDefault { startedAt, Some(completedAt), confirmed.size, + Some(ctx.target), jobRunId ) _ <- RecruitmentRun.update(finalRun) @@ -484,12 +488,15 @@ object RecruitmentApp extends ZIOAppDefault { } invited <- RecruitmentCandidate.selectInvitedByRun(run.runId) evaluatedCount <- RecruitmentCandidate.selectCountByRun(run.runId) - _ <- ZIO.logInfo(s"=== Recruitment Report for $clubSlug (run ${run.runId}) ===") - _ <- ZIO.logInfo(s"Started: ${run.startedAt}") - _ <- ZIO.logInfo(s"Completed: ${run.completedAt.getOrElse("in progress")}") - _ <- ZIO.logInfo(s"Evaluated: $evaluatedCount | Invited: ${invited.size}") resolvedMap <- Player.resolveUsernames(invited.map(_.playerId)) - usernames = invited.map(c => resolvedMap.getOrElse(c.playerId, Username.wrap(s"[pid=${c.playerId}]"))) + // Drop unresolved player_ids (see the scout path): paste-ready invite list, identical to the server's + // `usernamesFor`, so `report`'s file matches `--report`/clipboard. + usernames = invited.flatMap(c => resolvedMap.get(c.playerId)) + _ <- ZIO.logInfo(s"=== Recruitment Report for $clubSlug (run ${run.runId}) ===") + _ <- ZIO.logInfo(s"Started: ${run.startedAt}") + _ <- ZIO.logInfo(s"Completed: ${run.completedAt.getOrElse("in progress")}") + // Report the paste-ready count (post-drop), matching the file's stat line and the --report/clipboard payload. + _ <- ZIO.logInfo(s"Evaluated: $evaluatedCount | Invited: ${usernames.size}") _ <- ZIO.logInfo(usernames.mkString(" ")) _ <- ZIO.logInfo("") _ <- ZIO.foreachDiscard(usernames) { name => diff --git a/src/main/scala/ccas/analysis/apps/recruitment/RecruitmentExplore.scala b/src/main/scala/ccas/analysis/apps/recruitment/RecruitmentExplore.scala index 31da37c..f0e7386 100644 --- a/src/main/scala/ccas/analysis/apps/recruitment/RecruitmentExplore.scala +++ b/src/main/scala/ccas/analysis/apps/recruitment/RecruitmentExplore.scala @@ -217,8 +217,10 @@ private[recruitment] object RecruitmentExplore { ) } yield () - /** When found count exceeds the target, trim the newest excess from invitedRef. All candidates are already Deferred - * in the DB; no status flip needed here. + /** When found count exceeds the target, trim the newest excess from invitedRef so the loop stops and the auto-confirm + * `found` list is capped. The excess candidates stay Deferred in the DB (carried to the next run via + * `selectDeferredByClub`); the interactive confirm path caps its flip at the run's target, so a chunk overshoot never + * invites beyond target. No DB write is needed here. */ def reclassifyExcessInvited(ctx: ExploreContext): RIO[PostgresClient, Unit] = // invitedRef is prepend-ordered (newest first), so drop excess from the head diff --git a/src/main/scala/ccas/analysis/tables/RecruitmentCandidate.scala b/src/main/scala/ccas/analysis/tables/RecruitmentCandidate.scala index 322304f..2e5542f 100644 --- a/src/main/scala/ccas/analysis/tables/RecruitmentCandidate.scala +++ b/src/main/scala/ccas/analysis/tables/RecruitmentCandidate.scala @@ -118,25 +118,47 @@ object RecruitmentCandidate { .query[Int].run().headOption }.someOrFail(new SQLException("COUNT query produced no rows")) - // The run's still-deferred candidates, for the interactive `ccas recruit` confirm prompt (a deferred-confirm run - // leaves everything Deferred; the operator reviews these before any are marked Invited). Ordered by player_id to - // match `selectInvitedByRun`'s deterministic output. + // The run's still-deferred candidates the operator will confirm, for the interactive `ccas recruit` confirm prompt (a + // deferred-confirm run leaves everything Deferred; the operator reviews these before any are marked Invited). Capped + // at the run's remaining budget — `target` minus those already Invited this run — so a chunk overshoot isn't + // shown/confirmed above target and a re-fetch after a partial confirm shows only what's still confirmable. The excess + // stays Deferred and carries to the next run. Legacy runs (NULL target) fall through to `LIMIT NULL` (no cap). Ordered + // by player_id to match `selectInvitedByRun` and `confirmDeferredByRun`. def selectDeferredByRun(runId: RecruitmentRunId): ZIO[PostgresClient, SQLException, List[RecruitmentCandidate]] = connectZIO { val deferred: CandidateOutcome = CandidateOutcome.Deferred + val invited: CandidateOutcome = CandidateOutcome.Invited sql"""SELECT $selectCols FROM recruitment_candidate WHERE run_id = $runId AND outcome = $deferred - ORDER BY player_id""" + ORDER BY player_id + LIMIT (SELECT CASE WHEN rr.target IS NULL THEN NULL + ELSE GREATEST(rr.target - (SELECT COUNT(*) FROM recruitment_candidate + WHERE run_id = $runId AND outcome = $invited), 0) + END + FROM recruitment_run rr WHERE rr.run_id = $runId)""" .query[RecruitmentCandidate].run().toList } - // Confirm a deferred-confirm run: flip its Deferred candidates to Invited in one statement. Returns rows affected - // (0 if already confirmed or nothing found), so the caller can update `recruitment_run.candidates_found`. + // Confirm a deferred-confirm run: flip its remaining-budget Deferred candidates (lowest player_ids first) to Invited. + // Matches `selectDeferredByRun`'s cap+order so the operator invites exactly what the prompt showed. The cap is + // `target - already-invited-this-run`, so a re-POST after a full confirm flips 0 (idempotent, per the endpoint's + // contract) rather than sweeping up the still-Deferred overshoot; that excess carries to the next run. Legacy runs + // (NULL target) flip all Deferred. Returns rows affected (0 if already confirmed or nothing found), so the caller can + // update `recruitment_run.candidates_found`. def confirmDeferredByRun(runId: RecruitmentRunId): ZIO[PostgresClient, SQLException, Int] = connectZIO { val invited: CandidateOutcome = CandidateOutcome.Invited val deferred: CandidateOutcome = CandidateOutcome.Deferred sql"""UPDATE recruitment_candidate SET outcome = $invited - WHERE run_id = $runId AND outcome = $deferred""".update.run() + WHERE run_id = $runId AND player_id IN ( + SELECT player_id FROM recruitment_candidate + WHERE run_id = $runId AND outcome = $deferred + ORDER BY player_id + LIMIT (SELECT CASE WHEN rr.target IS NULL THEN NULL + ELSE GREATEST(rr.target - (SELECT COUNT(*) FROM recruitment_candidate + WHERE run_id = $runId AND outcome = $invited), 0) + END + FROM recruitment_run rr WHERE rr.run_id = $runId) + ) AND outcome = $deferred""".update.run() } /** Returns deferred candidates for a club that have not been resolved (Invited/Rejected) in a later run. */ diff --git a/src/main/scala/ccas/analysis/tables/RecruitmentRun.scala b/src/main/scala/ccas/analysis/tables/RecruitmentRun.scala index 3a8e65e..14e408b 100644 --- a/src/main/scala/ccas/analysis/tables/RecruitmentRun.scala +++ b/src/main/scala/ccas/analysis/tables/RecruitmentRun.scala @@ -21,12 +21,16 @@ final case class RecruitmentRun( startedAt: Instant, completedAt: Option[Instant], candidatesFound: Int, + // The run's effective target (invite budget). Read by `selectDeferredByRun` / `confirmDeferredByRun` to cap the + // interactive confirm at target, leaving any chunk-overshoot excess Deferred to carry forward. NULL only for legacy + // rows created before this column existed (treated as "no cap"). + target: Option[Int], jobRunId: Option[JobRunId] ) derives DbCodec object RecruitmentRun { private val selectCols = SqlLiteral( - "run_id, club_id, criteria_id, trigger, started_at, completed_at, candidates_found, job_run_id" + "run_id, club_id, criteria_id, trigger, started_at, completed_at, candidates_found, target, job_run_id" ) def createTable: ZIO[PostgresClient, SQLException, Int] = @@ -39,6 +43,7 @@ object RecruitmentRun { started_at TIMESTAMPTZ NOT NULL, completed_at TIMESTAMPTZ, candidates_found INT NOT NULL, + target INT, job_run_id TEXT, FOREIGN KEY (club_id) REFERENCES club (club_id) ON DELETE RESTRICT, FOREIGN KEY (criteria_id) REFERENCES recruitment_criteria (criteria_id) ON DELETE RESTRICT @@ -82,11 +87,12 @@ object RecruitmentRun { criteriaId: Long, trigger: RunTrigger, startedAt: Instant, + target: Option[Int], jobRunId: Option[JobRunId] ): ZIO[PostgresClient, SQLException, RecruitmentRunId] = connectZIO { - sql"""INSERT INTO recruitment_run (club_id, criteria_id, trigger, started_at, candidates_found, job_run_id) - VALUES ($clubId, $criteriaId, $trigger, $startedAt, 0, $jobRunId) + sql"""INSERT INTO recruitment_run (club_id, criteria_id, trigger, started_at, candidates_found, target, job_run_id) + VALUES ($clubId, $criteriaId, $trigger, $startedAt, 0, $target, $jobRunId) RETURNING run_id""".query[RecruitmentRunId].run().headOption }.someOrFail(new SQLException("INSERT RETURNING produced no rows")) diff --git a/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentAppCore.scala b/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentAppCore.scala index ac4fbbd..dd9b6b8 100644 --- a/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentAppCore.scala +++ b/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentAppCore.scala @@ -84,7 +84,7 @@ object TestRecruitmentAppCore extends ZIOSpecDefault { for { _ <- seedDb criteriaId <- seedCriteria(makeCriteria()) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) loaded <- RecruitmentRun.selectId(runId) } yield assertTrue( runId.value > 0L, @@ -99,9 +99,9 @@ object TestRecruitmentAppCore extends ZIOSpecDefault { for { _ <- seedDb criteriaId <- seedCriteria(makeCriteria()) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) _ <- RecruitmentRun.update( - RecruitmentRun(runId, clubId, criteriaId, RunTrigger.Cli, Times.t0, Some(Times.t1), 5, None) + RecruitmentRun(runId, clubId, criteriaId, RunTrigger.Cli, Times.t0, Some(Times.t1), 5, None, None) ) loaded <- RecruitmentRun.selectId(runId) } yield assertTrue( @@ -113,8 +113,8 @@ object TestRecruitmentAppCore extends ZIOSpecDefault { for { _ <- seedDb criteriaId <- seedCriteria(makeCriteria()) - _ <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) - runId2 <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t1, None) + _ <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) + runId2 <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t1, None, None) latest <- RecruitmentRun.selectLatest(clubId) } yield assertTrue( latest.isDefined, @@ -127,7 +127,7 @@ object TestRecruitmentAppCore extends ZIOSpecDefault { _ <- seedPlayer(pid0) _ <- seedPlayer(pid1) criteriaId <- seedCriteria(makeCriteria()) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) _ <- RecruitmentCandidate .insert( RecruitmentCandidate(runId, pid0, Times.t0, CandidateOutcome.Invited, None) @@ -145,7 +145,7 @@ object TestRecruitmentAppCore extends ZIOSpecDefault { _ <- seedPlayer(pid0) _ <- seedPlayer(pid1) criteriaId <- seedCriteria(makeCriteria()) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) _ <- RecruitmentCandidate .insert( RecruitmentCandidate(runId, pid0, Times.t0, CandidateOutcome.Invited, None) @@ -167,7 +167,7 @@ object TestRecruitmentAppCore extends ZIOSpecDefault { _ <- seedPlayer(pid1) _ <- seedPlayer(pid2) criteriaId <- seedCriteria(makeCriteria()) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) // Insert out of player_id order; the query must return them ascending regardless. _ <- ZIO.foreachDiscard(List(pid2, pid0, pid1)) { pid => RecruitmentCandidate.insert(RecruitmentCandidate(runId, pid, Times.t0, CandidateOutcome.Invited, None)) @@ -187,10 +187,10 @@ object TestRecruitmentAppCore extends ZIOSpecDefault { nowT <- dbNow earlier = nowT.minusSeconds(120) // Two completed runs today for the same club/alias. - runA <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, nowT, None) - _ <- RecruitmentRun.update(RecruitmentRun(runA, clubId, criteriaId, RunTrigger.Cli, nowT, Some(nowT), 0, None)) - runB <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, nowT, None) - _ <- RecruitmentRun.update(RecruitmentRun(runB, clubId, criteriaId, RunTrigger.Cli, nowT, Some(nowT), 0, None)) + runA <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, nowT, None, None) + _ <- RecruitmentRun.update(RecruitmentRun(runA, clubId, criteriaId, RunTrigger.Cli, nowT, Some(nowT), 0, None, None)) + runB <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, nowT, None, None) + _ <- RecruitmentRun.update(RecruitmentRun(runB, clubId, criteriaId, RunTrigger.Cli, nowT, Some(nowT), 0, None, None)) // pid0 invited in BOTH runs (the duplicate); pid1 only in A; pid2 only in B. _ <- RecruitmentCandidate.insert(RecruitmentCandidate(runA, pid0, earlier, CandidateOutcome.Invited, None)) _ <- RecruitmentCandidate.insert(RecruitmentCandidate(runB, pid0, nowT, CandidateOutcome.Invited, None)) @@ -212,7 +212,7 @@ object TestRecruitmentAppCore extends ZIOSpecDefault { _ <- ZIO.foreachDiscard(enumPids)(seedPlayer) outcomes = CandidateOutcome.values.toList criteriaId <- seedCriteria(makeCriteria()) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) _ <- ZIO.foreachDiscard(outcomes.zip(enumPids)) { (outcome, pid) => RecruitmentCandidate .insert( @@ -231,8 +231,8 @@ object TestRecruitmentAppCore extends ZIOSpecDefault { _ <- seedDb _ <- seedPlayer(pid0) criteriaId <- seedCriteria(makeCriteria()) - runId1 <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) - runId2 <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t1, None) + runId1 <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) + runId2 <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t1, None, None) _ <- RecruitmentCandidate .insert( RecruitmentCandidate(runId1, pid0, Times.t0, CandidateOutcome.Invited, None) @@ -254,7 +254,7 @@ object TestRecruitmentAppCore extends ZIOSpecDefault { _ <- Club.upsert(otherClub) _ <- seedPlayer(pid0) criteriaId <- seedCriteria(makeCriteria()) - otherRunId <- RecruitmentRun.insert(sourceClubId, criteriaId, RunTrigger.Cli, Times.t0, None) + otherRunId <- RecruitmentRun.insert(sourceClubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) _ <- RecruitmentCandidate.insert( RecruitmentCandidate(otherRunId, pid0, Times.t0, CandidateOutcome.Invited, None) ) @@ -417,9 +417,9 @@ object TestRecruitmentAppCore extends ZIOSpecDefault { _ <- seedPlayer(pid0) _ <- seedPlayer(pid1) criteriaId <- seedCriteria(makeCriteria()) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) _ <- RecruitmentRun.update( - RecruitmentRun(runId, clubId, criteriaId, RunTrigger.Cli, Times.t0, Some(Times.t1), 2, None) + RecruitmentRun(runId, clubId, criteriaId, RunTrigger.Cli, Times.t0, Some(Times.t1), 2, None, None) ) _ <- RecruitmentCandidate .insert( @@ -437,9 +437,9 @@ object TestRecruitmentAppCore extends ZIOSpecDefault { _ <- seedDb _ <- seedPlayer(pid0) criteriaId <- seedCriteria(makeCriteria()) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) _ <- RecruitmentRun.update( - RecruitmentRun(runId, clubId, criteriaId, RunTrigger.Cli, Times.t0, Some(Times.t1), 1, None) + RecruitmentRun(runId, clubId, criteriaId, RunTrigger.Cli, Times.t0, Some(Times.t1), 1, None, None) ) _ <- RecruitmentCandidate .insert( @@ -471,7 +471,7 @@ object TestRecruitmentAppCore extends ZIOSpecDefault { _ <- seedPlayer(PlayerId(999)) _ <- seedMatchWithBoard(refMatchId, Some(clubId), candidatePid, PlayerId(999)) client <- fakeChessComClient(responses) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Instant.now(), None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Instant.now(), None, None) _ <- evalCandidates(client, runId, List(Username("ref-db-player")), criteria) ref <- PlayerMatchRef.selectId(candidatePid) } yield assertTrue( @@ -504,7 +504,7 @@ object TestRecruitmentAppCore extends ZIOSpecDefault { _ <- seedDb criteriaId <- seedCriteria(criteria) client <- fakeChessComClient(responses) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Instant.now(), None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Instant.now(), None, None) _ <- evalCandidates(client, runId, List(Username("ref-api-player")), criteria) ref <- PlayerMatchRef.selectId(candidatePid) } yield assertTrue( @@ -560,7 +560,7 @@ object TestRecruitmentAppCore extends ZIOSpecDefault { _ <- seedDb criteriaId <- seedCriteria(criteria) client <- fakeChessComClient(responses) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Instant.now(), None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Instant.now(), None, None) invited <- evalCandidates(client, runId, List(Username("ref-fail-player")), criteria) ref <- PlayerMatchRef.selectId(candidatePid) } yield assertTrue( diff --git a/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentAppExplore.scala b/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentAppExplore.scala index 8b2cd9e..d44ef69 100644 --- a/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentAppExplore.scala +++ b/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentAppExplore.scala @@ -165,9 +165,10 @@ object TestRecruitmentAppExplore extends ZIOSpecDefault { deferred.nonEmpty ) } @@ TestAspect.withLiveClock, - test("excess invited candidates are reclassified as Deferred") { + test("excess invited candidates stay Deferred and carry forward (auto-confirm)") { val source = ClubSlug("defer-source") - // 6 candidates, target=2 → should get exactly 2 Invited, rest Deferred or Rejected + // 6 candidates, target=2 → exactly 2 Invited; the 4 chunk-overshoot passers stay Deferred (NOT deleted) so they + // carry to the next run via selectDeferredByClub. val candidateNames = (0 to 5).map(i => s"defer-cand-$i").toList val responses = Map( s"club/$clubSlug" -> apiClubJson(clubId.value, clubSlug.value), @@ -192,7 +193,51 @@ object TestRecruitmentAppExplore extends ZIOSpecDefault { } yield assertTrue( invited.size == 2, result.candidatesFound == 2, - invited.size + deferred.size >= 2 + deferred.size == candidateNames.size - 2 + ) + }, + test("deferred-confirm caps invited at target, leaving excess Deferred for next run") { + // Regression for the 31-vs-30 bug: a chunk overshoot persists more Deferred than target, and the old uncapped + // confirm flipped ALL of them. Now /found (selectDeferredByRun) and the confirm both cap at the run's stored + // target, and the excess stays Deferred to carry forward. + val source = ClubSlug("confirm-source") + val candidateNames = (0 to 5).map(i => s"confirm-cand-$i").toList + val responses = Map( + s"club/$clubSlug" -> apiClubJson(clubId.value, clubSlug.value), + s"club/$clubSlug/members" -> apiClubMembersJson(Nil), + s"club/$source" -> apiClubJson(ClubId(902).value, source.value), + s"club/$source/members" -> apiClubMembersJson( + candidateNames.map(n => (n, Times.t0.getEpochSecond)) + ) + ) ++ candidateNames.zipWithIndex.map { (name, i) => + s"player/$name" -> apiPlayerJson(410 + i, name) + }.toMap + val criteria = makeCriteria() + + for { + _ <- seedDb + _ <- seedCriteria(criteria) + client <- fakeChessComClient(responses) + result <- runRecruit(client, target = Some(2), sourceClubs = List(source), autoConfirm = false) + allBefore <- RecruitmentCandidate.selectByRun(result.runId) + allDeferredBefore = allBefore.filter(_.outcome == CandidateOutcome.Deferred) + foundBefore <- RecruitmentCandidate.selectDeferredByRun(result.runId) + firstFlip <- RecruitmentCandidate.confirmDeferredByRun(result.runId) + invited <- RecruitmentCandidate.selectInvitedByRun(result.runId) + // Re-POST must be idempotent (JobRoutes contract): flip 0, invited stays at target — it must NOT sweep up the + // still-Deferred overshoot. + secondFlip <- RecruitmentCandidate.confirmDeferredByRun(result.runId) + invitedAfterRepost <- RecruitmentCandidate.selectInvitedByRun(result.runId) + allAfter <- RecruitmentCandidate.selectByRun(result.runId) + deferredAfter = allAfter.filter(_.outcome == CandidateOutcome.Deferred) + } yield assertTrue( + allDeferredBefore.size == candidateNames.size, // all 6 evaluated, nothing deleted + foundBefore.size == 2, // /found capped at target + firstFlip == 2, // confirm flips exactly target + invited.size == 2, // confirm capped at target + secondFlip == 0, // re-POST flips nothing (idempotent) + invitedAfterRepost.size == 2, // still exactly target after re-POST + deferredAfter.size == candidateNames.size - 2 // excess (4) carried forward as Deferred ) }, test("deferred candidates from prior run are prioritised in next run") { @@ -216,9 +261,9 @@ object TestRecruitmentAppExplore extends ZIOSpecDefault { client <- fakeChessComClient(responses) // Seed prior run with a Deferred candidate (need Player row) _ <- Player.insert(Player(PlayerId(500), Times.t0, Username("prio-deferred"), Active, None, Times.t0)) - priorRunId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + priorRunId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) _ <- RecruitmentRun.update( - RecruitmentRun(priorRunId, clubId, criteriaId, RunTrigger.Cli, Times.t0, Some(Times.t1), 0, None) + RecruitmentRun(priorRunId, clubId, criteriaId, RunTrigger.Cli, Times.t0, Some(Times.t1), 0, None, None) ) _ <- RecruitmentCandidate.insert( RecruitmentCandidate(priorRunId, PlayerId(500), Times.t0, CandidateOutcome.Deferred, None) @@ -253,9 +298,9 @@ object TestRecruitmentAppExplore extends ZIOSpecDefault { _ <- Player.insert(Player(PlayerId(600), Times.t0, Username("resolved-player"), Active, None, Times.t0)) // Run 1: candidate is Deferred - runId1 <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId1 <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) _ <- RecruitmentRun.update( - RecruitmentRun(runId1, clubId, criteriaId, RunTrigger.Cli, Times.t0, Some(Times.t1), 0, None) + RecruitmentRun(runId1, clubId, criteriaId, RunTrigger.Cli, Times.t0, Some(Times.t1), 0, None, None) ) _ <- RecruitmentCandidate.insert( RecruitmentCandidate(runId1, PlayerId(600), Times.t0, CandidateOutcome.Deferred, None) @@ -265,9 +310,9 @@ object TestRecruitmentAppExplore extends ZIOSpecDefault { deferredBefore <- RecruitmentCandidate.selectDeferredByClub(clubId) // Run 2: same candidate is Invited (later timestamp) - runId2 <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t2, None) + runId2 <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t2, None, None) _ <- RecruitmentRun.update( - RecruitmentRun(runId2, clubId, criteriaId, RunTrigger.Cli, Times.t2, Some(Times.t3), 1, None) + RecruitmentRun(runId2, clubId, criteriaId, RunTrigger.Cli, Times.t2, Some(Times.t3), 1, None, None) ) _ <- RecruitmentCandidate.insert( RecruitmentCandidate(runId2, PlayerId(600), Times.t2, CandidateOutcome.Invited, None) diff --git a/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentAppNetworkOutage.scala b/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentAppNetworkOutage.scala index b2d33e2..6caf239 100644 --- a/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentAppNetworkOutage.scala +++ b/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentAppNetworkOutage.scala @@ -36,7 +36,7 @@ object TestRecruitmentAppNetworkOutage extends ZIOSpecDefault { for { _ <- seedDb criteriaId <- seedCriteria(criteria) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- TestChessComClientSupport.networkDownClient discovered <- Ref.make(Set.empty[Username]) failed <- Ref.make(Set.empty[ClubSlug]) diff --git a/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentBlacklist.scala b/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentBlacklist.scala index 2d7b2fb..24e1e27 100644 --- a/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentBlacklist.scala +++ b/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentBlacklist.scala @@ -45,7 +45,7 @@ object TestRecruitmentBlacklist extends ZIOSpecDefault { _ <- RecruitmentBlacklist.insert( RecruitmentBlacklist(clubId, pid0, Times.t0, expiresAt = None, reason = Some("banned")) ) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(responses) _ <- evalCandidates(client, runId, List(Username("alice")), criteria) cands <- RecruitmentCandidate.selectByRun(runId) @@ -73,7 +73,7 @@ object TestRecruitmentBlacklist extends ZIOSpecDefault { reason = Some("temp ban") ) ) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, now, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, now, None, None) client <- fakeChessComClient(responses) _ <- evalCandidates(client, runId, List(Username("alice")), criteria) cands <- RecruitmentCandidate.selectByRun(runId) diff --git a/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentEvaluation.scala b/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentEvaluation.scala index 8633f57..f2cbe99 100644 --- a/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentEvaluation.scala +++ b/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentEvaluation.scala @@ -43,7 +43,7 @@ object TestRecruitmentEvaluation extends ZIOSpecDefault { for { _ <- seedDb criteriaId <- seedCriteria(criteria) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(responses) invited <- evalCandidates(client, runId, List(Username("alice"), Username("bob")), criteria) // Check invited list @@ -74,7 +74,7 @@ object TestRecruitmentEvaluation extends ZIOSpecDefault { for { _ <- seedDb criteriaId <- seedCriteria(criteria) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(responses) invited <- evalCandidates( client, @@ -99,7 +99,7 @@ object TestRecruitmentEvaluation extends ZIOSpecDefault { for { _ <- seedDb criteriaId <- seedCriteria(criteria) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(responses, failures = Set("bob")) invited <- evalCandidates(client, runId, List(Username("alice"), Username("bob")), criteria) candidates <- RecruitmentCandidate.selectByRun(runId) @@ -122,7 +122,7 @@ object TestRecruitmentEvaluation extends ZIOSpecDefault { for { _ <- seedDb criteriaId <- seedCriteria(criteria) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(responses) _ <- evalCandidates(client, runId, List(Username("alice")), criteria) candidates <- RecruitmentCandidate.selectByRun(runId) @@ -152,7 +152,7 @@ object TestRecruitmentEvaluation extends ZIOSpecDefault { _ <- seedPlayer(cache.playerId) _ <- PlayerRecruitmentCache.upsert(cache) criteriaId <- seedCriteria(criteria) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(responses) _ <- evalCandidates(client, runId, List(Username.wrap(username)), criteria) cands <- RecruitmentCandidate.selectByRun(runId) diff --git a/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentFilterChain.scala b/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentFilterChain.scala index d8c9c65..8fb3d7d 100644 --- a/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentFilterChain.scala +++ b/src/test/scala/ccas/analysis/apps/recruitment/TestRecruitmentFilterChain.scala @@ -37,7 +37,7 @@ object TestRecruitmentFilterChain extends ZIOSpecDefault { for { _ <- seedDb criteriaId <- seedCriteria(criteria) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(responses) invited <- evalCandidates(client, runId, List(Username.wrap(username)), criteria) } yield @@ -233,7 +233,7 @@ object TestRecruitmentFilterChain extends ZIOSpecDefault { for { _ <- seedDb criteriaId <- seedCriteria(criteria) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(responses) _ <- evalCandidates(client, runId, List(Username.wrap("alice")), criteria) failures <- ApiFetchFailure.selectRecent(now.minus(Duration.ofMinutes(1))) @@ -273,7 +273,7 @@ object TestRecruitmentFilterChain extends ZIOSpecDefault { for { _ <- seedDb criteriaId <- seedCriteria(criteria) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(responses) _ <- evalCandidates(client, runId, List(Username.wrap("alice")), criteria) cache <- PlayerRecruitmentCache.selectId(pid0) @@ -321,7 +321,7 @@ object TestRecruitmentFilterChain extends ZIOSpecDefault { _ <- seedDb _ <- Club.upsert(Club(bannedClubId, Times.t0, ClubSlug("banned-club"), "Banned Club", None, None, None)) criteriaId <- seedCriteria(criteria) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(responses) _ <- evalCandidates(client, runId, List(Username.wrap("alice")), criteria) cands <- RecruitmentCandidate.selectByRun(runId) @@ -355,12 +355,12 @@ object TestRecruitmentFilterChain extends ZIOSpecDefault { // Seed player row for FK constraint _ <- seedPlayer(pid0) // Create a prior run with alice invited recently - priorRunId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Instant.now(), None) + priorRunId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Instant.now(), None, None) _ <- RecruitmentCandidate.insert( RecruitmentCandidate(priorRunId, pid0, Instant.now(), CandidateOutcome.Invited, None) ) // Now evaluate alice again - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(Map("player/alice" -> apiPlayerJson(200, "alice"))) _ <- evalCandidates(client, runId, List(Username("alice")), criteria) cands <- RecruitmentCandidate.selectByRun(runId) @@ -451,7 +451,7 @@ object TestRecruitmentFilterChain extends ZIOSpecDefault { for { _ <- seedDb criteriaId <- seedCriteria(criteria) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(responses) _ <- evalCandidates(client, runId, List(Username("alice")), criteria) cached <- PlayerRecruitmentCache.selectId(pid0) @@ -474,7 +474,7 @@ object TestRecruitmentFilterChain extends ZIOSpecDefault { // Seed alice as a former member of the club (player row needed for FK) _ <- seedPlayer(pid0) _ <- ClubMember.insert(ClubMember(clubId, pid0, Times.t0, Some(Times.t1), sinceApproximate = false)) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(responses) _ <- evalCandidates(client, runId, List(Username("alice")), criteria) cands <- RecruitmentCandidate.selectByRun(runId) @@ -490,7 +490,7 @@ object TestRecruitmentFilterChain extends ZIOSpecDefault { // Seed alice as a former member of the club _ <- seedPlayer(pid0) _ <- ClubMember.insert(ClubMember(clubId, pid0, Times.t0, Some(Times.t1), sinceApproximate = false)) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(responses) _ <- evalCandidates(client, runId, List(Username("alice")), criteria) cands <- RecruitmentCandidate.selectByRun(runId) @@ -507,7 +507,7 @@ object TestRecruitmentFilterChain extends ZIOSpecDefault { _ <- seedPlayer(pid0) _ <- ClubAdmin.insertBatch(List(ClubAdmin(sizableClubId, pid0))) criteriaId <- seedCriteria(criteria) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(responses) _ <- evalCandidates(client, runId, List(Username("alice")), criteria) cands <- RecruitmentCandidate.selectByRun(runId) @@ -524,7 +524,7 @@ object TestRecruitmentFilterChain extends ZIOSpecDefault { _ <- seedPlayer(pid0) _ <- ClubAdmin.insertBatch(List(ClubAdmin(sizableClubId, pid0))) criteriaId <- seedCriteria(criteria) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(responses) _ <- evalCandidates(client, runId, List(Username("alice")), criteria) cands <- RecruitmentCandidate.selectByRun(runId) @@ -548,7 +548,7 @@ object TestRecruitmentFilterChain extends ZIOSpecDefault { for { _ <- seedDb criteriaId <- seedCriteria(criteria) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(responses) _ <- evalCandidates(client, runId, List(Username("alice")), criteria) cands <- RecruitmentCandidate.selectByRun(runId) @@ -582,7 +582,7 @@ object TestRecruitmentFilterChain extends ZIOSpecDefault { for { _ <- seedDb criteriaId <- seedCriteria(criteria) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(responses) _ <- evalCandidates(client, runId, List(Username("alice"), Username("bob")), criteria) cands <- RecruitmentCandidate.selectByRun(runId) @@ -609,7 +609,7 @@ object TestRecruitmentFilterChain extends ZIOSpecDefault { for { _ <- seedDb criteriaId <- seedCriteria(criteria) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Cli, Times.t0, None, None) client <- fakeChessComClient(responses) _ <- evalCandidates(client, runId, List(Username("alice")), criteria) cands <- RecruitmentCandidate.selectByRun(runId) diff --git a/src/test/scala/ccas/server/routes/TestRoutes.scala b/src/test/scala/ccas/server/routes/TestRoutes.scala index e3c89ec..8823a73 100644 --- a/src/test/scala/ccas/server/routes/TestRoutes.scala +++ b/src/test/scala/ccas/server/routes/TestRoutes.scala @@ -487,7 +487,7 @@ object TestRoutes extends ZIOSpecDefault { for { _ <- ensureClubs criteriaId <- RecruitmentCriteria.insert(RecruitmentCriteria.defaultDaily) - runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Api, t0, Some(JobRunId.wrap(jobId))) + runId <- RecruitmentRun.insert(clubId, criteriaId, RunTrigger.Api, t0, None, Some(JobRunId.wrap(jobId))) _ <- ZIO.foreachDiscard(invited ++ deferred) { case (pid, name) => seedPlayer(pid, name) } _ <- ZIO.foreachDiscard(invited) { case (pid, _) => RecruitmentCandidate.insert(RecruitmentCandidate(runId, PlayerId(pid), t0, CandidateOutcome.Invited, None))