diff --git a/app/Actions/Interview/BuildInterviewOutline.php b/app/Actions/Interview/BuildInterviewOutline.php new file mode 100644 index 00000000..2748488f --- /dev/null +++ b/app/Actions/Interview/BuildInterviewOutline.php @@ -0,0 +1,141 @@ +} + */ + public function handle(Participant $participant, Project $project, AssessmentType $type): array + { + $locale = $project->language ?? (string) config('app.fallback_locale', 'en'); + $ordered = $this->progress->ordered($project->id)->values(); + + $sessions = InterviewSession::where('participant_id', $participant->id) + ->where('project_id', $project->id) + ->get(['competency_code', 'status', 'error_count', 'primary_questions']) + ->keyBy('competency_code'); + + // Revision-unique ids: no code lookup and no resolver call is needed for the names. + $competencies = Competency::whereIn('id', $ordered->pluck('competency_id'))->get()->keyBy('id'); + + $entries = []; + $currentFound = false; + + foreach ($ordered as $index => $row) { + $session = $sessions->get($row->competency_code); + $open = CompetencyProgress::isOpen($session); + + $status = match (true) { + $open && ! $currentFound => 'current', + $open && $session !== null => 'retry', + $session === null => 'upcoming', + default => 'done', + }; + $currentFound = $currentFound || $status === 'current'; + + $competency = $competencies->get($row->competency_id); + + $entries[] = [ + 'name' => $competency === null ? null : $this->localizedName($competency, $locale), + 'ordinal' => $index + 1, + 'status' => $status, + 'primary_total' => $this->primaryTotal($project, (int) $row->competency_id, $session), + ]; + } + + return [ + 'assessment_type' => $type->value, + 'role_name' => $type === AssessmentType::Standard ? $this->roleName($project, $locale) : null, + 'avatar_name' => $project->avatarTemplate?->personaDisplayName(), + 'candidate_name' => $this->nonBlank($participant->display_name), + 'competencies' => $entries, + 'primary_total_all' => array_sum(array_column($entries, 'primary_total')), + ]; + } + + /** + * What the system will ask: the count frozen in the session when one exists (what the + * classifier counts against), otherwise the count it would freeze. + */ + private function primaryTotal(Project $project, int $competencyId, ?InterviewSession $session): int + { + if ($session?->primary_questions !== null) { + return count($session->primary_questions); + } + + // ponytail: per-competency primaries query (at most ~18 small indexed reads), batch into one + // grouped read if the outline shows up in p95. + return count($this->primaries->handle($project, $competencyId)); + } + + private function roleName(Project $project, string $locale): ?string + { + if ($project->role_code === null) { + return null; + } + + $revisionId = $this->revisions->tryForProject($project); + + if ($revisionId === null) { + return null; + } + + $role = Role::where('code', $project->role_code)->where('revision_id', $revisionId)->first(); + + return $role === null ? null : $this->localizedName($role, $locale); + } + + /** + * The project language, then the catalogue default; never the code (V3). + */ + private function localizedName(Competency|Role $model, string $locale): ?string + { + foreach ([$locale, (string) config('app.fallback_locale', 'en')] as $candidate) { + $name = $this->nonBlank($model->getTranslation('name', $candidate, false)); + + if ($name !== null) { + return $name; + } + } + + return null; + } + + private function nonBlank(mixed $value): ?string + { + $trimmed = is_string($value) ? trim($value) : ''; + + return $trimmed === '' ? null : $trimmed; + } +} diff --git a/app/Actions/Interview/SettleParticipantCompletion.php b/app/Actions/Interview/SettleParticipantCompletion.php index 117a5101..ad201365 100644 --- a/app/Actions/Interview/SettleParticipantCompletion.php +++ b/app/Actions/Interview/SettleParticipantCompletion.php @@ -41,13 +41,13 @@ final class SettleParticipantCompletion * participant stranded before this change who comes back. * * THE DEFECT THIS REPAIRS: the tally counted only `completed|timeout|skipped` - * while `resolveNextCompetency()` treats `error` as terminal and skips it. A + * while `CompetencyProgress::resolveNext()` treats `error` as terminal and skips it. A * participant with one errored competency exhausted every competency while the * count stayed at `total - 1`, so the CAS never fired — no scoring, no webhook, * no notification, and nothing anywhere reported it. * * An `error` counts only once it has spent its re-offer. This predicate and the - * re-offer branch in `resolveNextCompetency()` are two halves of ONE behaviour + * re-offer branch in `CompetencyProgress::resolveNext()` are two halves of ONE behaviour * and cannot ship apart: count errors too early and a single transient provider * 4xx — our own bug — ends the interview with no second chance; count them too * late and a competency the resolver skips is never tallied, which is the diff --git a/app/Actions/Participant/RecoverFailedParticipant.php b/app/Actions/Participant/RecoverFailedParticipant.php index 3aad16c2..91b88416 100644 --- a/app/Actions/Participant/RecoverFailedParticipant.php +++ b/app/Actions/Participant/RecoverFailedParticipant.php @@ -26,7 +26,7 @@ * `InterviewSession` at status=error for that participant (-> pending, * cleared, utterances deleted) inside one `DB::transaction()` + row lock. * Sessions at completed/timeout/skipped are NEVER touched — the existing - * `InterviewController::resolveNextCompetency()` skip logic still treats + * `CompetencyProgress::resolveNext()` skip logic still treats * them as terminal-completed, so the candidate resumes at the failed * competency, never re-asked anything already answered. * diff --git a/app/Http/Controllers/Api/PlatformAvatarTemplateController.php b/app/Http/Controllers/Api/PlatformAvatarTemplateController.php index ca794764..942ff1e1 100644 --- a/app/Http/Controllers/Api/PlatformAvatarTemplateController.php +++ b/app/Http/Controllers/Api/PlatformAvatarTemplateController.php @@ -151,6 +151,8 @@ public function store(Request $request): JsonResponse $validated = $request->validate($this->templateStoreRules()); + $validated = array_merge($validated, $this->personaWrite($request, null)); + $this->assertConfigValid($validated['provider'], $validated['config']); app(BindHeygenTemplateVoice::class)->run($validated['provider'], $validated['config']); $this->assertNameFreeAmong(AvatarTemplate::platformOnly(), $validated['name'], null); @@ -195,6 +197,8 @@ public function update(Request $request, int $id): PlatformAvatarTemplateResourc $template = AvatarTemplate::platformOnly()->findOrFail($id); $validated = $request->validate($this->templateUpdateRules()); + $validated = array_merge($validated, $this->personaWrite($request, $template->persona)); + // The provider is immutable: the config's knobs belong to it, so a // template that changed provider would validate as empty and quietly // fall back to the environment defaults. diff --git a/app/Http/Controllers/AvatarTemplateController.php b/app/Http/Controllers/AvatarTemplateController.php index 1c7c8760..b48d2226 100644 --- a/app/Http/Controllers/AvatarTemplateController.php +++ b/app/Http/Controllers/AvatarTemplateController.php @@ -227,6 +227,8 @@ public function store(Request $request): JsonResponse $validated = $request->validate($this->templateStoreRules()); + $validated = array_merge($validated, $this->personaWrite($request, null)); + $this->assertConfigValid($validated['provider'], $validated['config']); // A superadmin's external voice is bound here, before the row is written, // exactly as on the platform route: the ledger and secrets are platform @@ -260,6 +262,8 @@ public function update(Request $request, int $id): AvatarTemplateResource $validated = $request->validate($this->templateUpdateRules()); + $validated = array_merge($validated, $this->personaWrite($request, $template->persona)); + // The provider is immutable. Changing it would leave every knob in the // config belonging to the other one — avatarId where faceId is // expected, with nothing overlapping — so the template would validate diff --git a/app/Http/Controllers/AvatarTemplatePortabilityController.php b/app/Http/Controllers/AvatarTemplatePortabilityController.php index 76691ce3..73595f98 100644 --- a/app/Http/Controllers/AvatarTemplatePortabilityController.php +++ b/app/Http/Controllers/AvatarTemplatePortabilityController.php @@ -9,6 +9,7 @@ use App\Models\AvatarTemplate; use App\Models\LlmCredential; use App\Models\LlmModel; +use App\Rules\PersonaDisplayName; use App\Support\AvatarTemplates\ConfigValidator; use App\Support\AvatarTemplates\ProviderFieldSpecs; use App\Support\AvatarTemplates\TemplateDocument; @@ -124,6 +125,16 @@ private function reject(array $record): ?string return "Unknown provider '{$record['provider']}'."; } + // The SAME rule the form applies to the interviewer's name: a file must never install a + // name the form would refuse. The message never repeats a matched vendor word. + if (isset($record['persona']['display_name'])) { + $nameProblem = PersonaDisplayName::problem($record['persona']['display_name']); + + if ($nameProblem !== null) { + return "persona.display_name: {$nameProblem}"; + } + } + // The SAME validator the create/edit form is built from (D7). It // already refuses unknown keys, so this must not grow a second check: // two validators drift, and the divergence would let a file install a diff --git a/app/Http/Controllers/Candidate/InterviewController.php b/app/Http/Controllers/Candidate/InterviewController.php index 222b5a0c..2fd1ce9e 100644 --- a/app/Http/Controllers/Candidate/InterviewController.php +++ b/app/Http/Controllers/Candidate/InterviewController.php @@ -46,6 +46,7 @@ use App\Services\Provider\TavusProvider; use App\Support\Catalogue\CatalogueRevisionResolver; use App\Support\Interview\AvatarSilenceDetector; +use App\Support\Interview\CompetencyProgress; use App\Support\Interview\ProviderRefLifetime; use App\Support\Interview\SessionLiveClock; use App\Support\Interview\SharedProviderRefGuard; @@ -101,6 +102,7 @@ public function __construct( private readonly SharedProviderRefGuard $siblings, private readonly AdvanceOnLiveConversation $advanceOnLiveConversation, private readonly ProviderRefLifetime $refLifetime, + private readonly CompetencyProgress $competencyProgress, ) {} // ========================================================================= @@ -238,7 +240,7 @@ public function start(Request $request): JsonResponse return response()->json(['error' => 'project_not_found'], Response::HTTP_UNPROCESSABLE_ENTITY); } - $nextCompetency = $this->resolveNextCompetency($pid, $project->id); + $nextCompetency = $this->competencyProgress->resolveNext($pid, $project->id); if ($nextCompetency === null) { $this->settleCompletionIfFinished($pid, $project->id); @@ -623,7 +625,7 @@ public function suspend(Request $request): JsonResponse * status ∈ {completed, timeout, skipped}, OR status = 'error' with * error_count >= MAX_ERROR_ATTEMPTS. That last disjunct is not optional — * settleCompletionIfFinished()'s docblock below explains at length that a - * tally disagreeing with resolveNextCompetency() is what stranded participants. + * tally disagreeing with CompetencyProgress::resolveNext() is what stranded participants. * (8) Last-question CAS: Participant::where(id, status=in_corso)->update(in_valutazione). * Only if $won === 1: dispatch FinalizeInterview::dispatch($pid)->afterCommit(). * (9) COMMIT. Return 200. @@ -901,12 +903,7 @@ private function freshRemainingCompetencies(int $participantId, int $projectId, ->pluck('competency_code') ->all(); - $all = DB::table('project_competencies as pc') - ->join('framework_competencies as fc', 'fc.id', '=', 'pc.competency_id') - ->where('pc.project_id', $projectId) - ->orderBy('pc.position') - ->pluck('fc.code') - ->values(); + $all = $this->competencyProgress->ordered($projectId)->pluck('competency_code')->values(); $remaining = []; @@ -972,101 +969,6 @@ private function activeInCorsoSession(int $participantId, string $competencyCode ->first(); } - /** - * Resolve the next competency for this participant (lowest position not yet terminal-completed). - * - * terminal-completed = status ∈ {completed, timeout, skipped} - * pending | in_corso → RESUME that session (no new creation needed). - * - * The ARRAY INDEX of this ordered list, not `position`, is what `competency_ordinal` is - * derived from (D6). `question_index` is persisted and equals `position` verbatim; - * `competency_ordinal` is derived per request and always dense (1..N), never stored. The - * two coincide for a dense, unreordered project, a consequence of well-formed data, not - * the reason `competency_ordinal` exists. - * - * The re-offer branch (D2) is ordered AFTER the pending|in_corso branch: a session already - * reset to `pending` is caught above and never reaches it, so the ratified participant-sso - * "Resume, not restart" scenario holds verbatim (the operator recovery path leaves sessions - * at `pending`, never at `error`). The reset happens at the call site, not here: this - * method resolves, it does not mutate. - * - * @return array{competency_code: string, question_index: int, competency_ordinal: int, total_competencies: int, reoffer?: bool}|null - * - * @internal - * All competencies ordered by position. competency_ordinal is the array index (D6), not position. - * Status and attempts spent in ONE query (the design promised one; sdd-verify caught the drift to two). - * No session yet → this is the next one to create - * pending | in_corso → RESUME this competency - * (D2) error with attempts left → RE-OFFER (ordered after pending|in_corso; see docblock). - * completed | timeout | skipped | exhausted error → skip to next - */ - private function resolveNextCompetency(int $participantId, int $projectId): ?array - { - $all = DB::table('project_competencies as pc') - ->join('framework_competencies as fc', 'fc.id', '=', 'pc.competency_id') - ->where('pc.project_id', $projectId) - ->orderBy('pc.position') - ->select('fc.code as competency_code', 'pc.position') - ->get(); - - $existing = InterviewSession::where('participant_id', $participantId) - ->where('project_id', $projectId) - ->get(['competency_code', 'status', 'error_count']) - ->keyBy('competency_code'); - - $total = $all->count(); - - foreach ($all->values() as $index => $row) { - $session = $existing->get($row->competency_code); - $status = $session?->status; - - if ($session === null) { - return $this->competencyPayload((array) $row, $index, $total); - } - - if (in_array($status, ['pending', 'in_corso'], true)) { - return $this->competencyPayload((array) $row, $index, $total); - } - - if ($status === 'error' - && $session->error_count < InterviewSession::MAX_ERROR_ATTEMPTS - ) { - return $this->competencyPayload((array) $row, $index, $total, reoffer: true); - } - - } - - return null; // all competencies terminal-completed - } - - /** - * Build the array `resolveNextCompetency()` returns for one row (D5). - * - * Collapses the three near-identical literals that used to live at the three - * return sites above into one construction. `question_index` MUST equal - * `$row->position` verbatim (D1) — `position` is 0-based at every writer, so - * the value needs no arithmetic here; the historic `- 1` produced `-1` for a - * project's first competency. - * - * @param array $row cast from the `project_competencies` ⋈ `framework_competencies` row - * @return array{competency_code: string, question_index: int, competency_ordinal: int, total_competencies: int, reoffer?: bool} - */ - private function competencyPayload(array $row, int $index, int $total, bool $reoffer = false): array - { - $payload = [ - 'competency_code' => (string) $row['competency_code'], - 'question_index' => (int) $row['position'], - 'competency_ordinal' => $index + 1, - 'total_competencies' => $total, - ]; - - if ($reoffer) { - $payload['reoffer'] = true; - } - - return $payload; - } - /** * Create a new session row or re-query an existing one (RESUME path). * diff --git a/app/Http/Controllers/Candidate/OutlineController.php b/app/Http/Controllers/Candidate/OutlineController.php new file mode 100644 index 00000000..e53d5b67 --- /dev/null +++ b/app/Http/Controllers/Candidate/OutlineController.php @@ -0,0 +1,70 @@ +}")] + #[ApiResponse(403, description: 'The participant is no longer in an active interview (completed or errored).', type: 'array{message: string}')] + #[ApiResponse(422, description: 'The project no longer exists (`project_not_found`) or its assessment type is not supported (`assessment_type_not_supported`).', type: "array{error: 'project_not_found'|'assessment_type_not_supported'}")] + public function show(Request $request, BuildInterviewOutline $outline): JsonResponse + { + /** @var Participant $participant */ + $participant = $request->user('api-candidate'); + + $project = $participant->project; + + if ($project === null) { + return response()->json(['error' => 'project_not_found'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $storedType = $project->getAttributes()['assessment_type'] ?? null; + $type = is_string($storedType) ? AssessmentType::tryFrom($storedType) : null; + + if ($type === null) { + return response()->json(['error' => 'assessment_type_not_supported'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return response()->json($outline->handle($participant, $project, $type)); + } +} diff --git a/app/Http/Controllers/Candidate/UtteranceController.php b/app/Http/Controllers/Candidate/UtteranceController.php index 791ae473..aa61098e 100644 --- a/app/Http/Controllers/Candidate/UtteranceController.php +++ b/app/Http/Controllers/Candidate/UtteranceController.php @@ -48,7 +48,7 @@ * `store()`. * * Response contract: - * - 202 Accepted → utterance persisted (session was in_corso at INSERT time); body `{boundary_due}` + * - 202 Accepted → utterance persisted (session was in_corso at INSERT time); body `{boundary_due, primary_index}` * - 404 Not Found → session not owned by authenticated candidate (resolveOwnedSession) * - 409 Conflict → session no longer in_corso at the atomic INSERT moment * - 422 Unprocessable → validation failed (missing required fields) @@ -92,7 +92,7 @@ public function __construct( */ // `#[Response(202, ...)]`, not `@scramble-return`: the PHPDoc form replaces the whole inferred // response set and would drop the documented 409 and 503. - #[Response(202, description: 'Utterance persisted. `boundary_due` is true once the competency has met its turn budget.', type: 'array{boundary_due: bool}')] + #[Response(202, description: 'Utterance persisted. `boundary_due` is true once the competency has met its turn budget. `primary_index` is how many distinct scripted questions of this competency have been asked so far.', type: 'array{boundary_due: bool, primary_index: int}')] public function store(Request $request): JsonResponse { $request->validate([ @@ -214,9 +214,15 @@ public function store(Request $request): JsonResponse ); } - $this->recordUtteranceEvent($session, $validated, $turnKind); + // Counted once, after the commit (outside the Z16 lock), and shared with the event below. + $primaryIndex = $this->turnClassifier->matchedCount($session); - return response()->json(['boundary_due' => $this->boundaryDue($session)], HttpResponse::HTTP_ACCEPTED); + $this->recordUtteranceEvent($session, $validated, $turnKind, $primaryIndex); + + return response()->json([ + 'boundary_due' => $this->boundaryDue($session), + 'primary_index' => $primaryIndex, + ], HttpResponse::HTTP_ACCEPTED); } /** @@ -260,14 +266,15 @@ private function boundaryDue(InterviewSession $session): bool * at `0` rather than going negative. * * @param array{session_id: int, speaker: string, text: string, ts: string} $validated + * @param int $primaryIndex `matchedCount()` as of this request's commit */ - private function recordUtteranceEvent(InterviewSession $session, array $validated, ?string $turnKind): void + private function recordUtteranceEvent(InterviewSession $session, array $validated, ?string $turnKind, int $primaryIndex): void { if ($validated['speaker'] === 'avatar' && $turnKind !== 'primary') { return; } - $questionIndex = max(0, $this->turnClassifier->matchedCount($session) - 1); + $questionIndex = max(0, $primaryIndex - 1); if ($validated['speaker'] === 'avatar') { InterviewEventRecorder::questionAsked($session->organization_id, $session->participant_id, $session->competency_code, $questionIndex); diff --git a/app/Http/Controllers/Concerns/ValidatesAvatarTemplateWrites.php b/app/Http/Controllers/Concerns/ValidatesAvatarTemplateWrites.php index 35e6b150..6274df4f 100644 --- a/app/Http/Controllers/Concerns/ValidatesAvatarTemplateWrites.php +++ b/app/Http/Controllers/Concerns/ValidatesAvatarTemplateWrites.php @@ -7,11 +7,14 @@ use App\Actions\ConversationLlm\ResyncTemplateBinding; use App\Models\AvatarTemplate; use App\Models\User; +use App\Rules\PersonaDisplayName; use App\Support\AvatarTemplates\ConfigValidator; use App\Support\AvatarTemplates\TemplateReferenceValidator; use Closure; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\UniqueConstraintViolationException; +use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; /** @@ -27,7 +30,7 @@ trait ValidatesAvatarTemplateWrites { /** - * @return array> + * @return array> */ private function templateStoreRules(): array { @@ -43,11 +46,12 @@ private function templateStoreRules(): array // Bind a language model to the template. `llm_model_id` and `llm_credential_id` are both set or both null. 'llm_model_id' => ['sometimes', 'nullable', 'integer'], 'llm_credential_id' => ['sometimes', 'nullable', 'integer'], + ...$this->personaRules(), ]; } /** - * @return array> + * @return array> */ private function templateUpdateRules(): array { @@ -63,9 +67,64 @@ private function templateUpdateRules(): array // Bind a language model to the template. `llm_model_id` and `llm_credential_id` are both set or both null; sending both as null clears the binding. 'llm_model_id' => ['sometimes', 'nullable', 'integer'], 'llm_credential_id' => ['sometimes', 'nullable', 'integer'], + ...$this->personaRules(), ]; } + /** + * The interviewer's display name (live-interview-call-chrome D7). The closure admits no other + * persona key, so the write cannot smuggle one in; keys an import brought in are kept by + * `personaWrite()`, not by the payload. It is a closure and not `array:display_name` because + * Scramble publishes every key of that rule as REQUIRED, which would make the generated client + * refuse `persona: {}`, the payload that clears the name. + * + * @return array> + */ + private function personaRules(): array + { + return [ + 'persona' => ['sometimes', 'array', static function (string $attribute, mixed $value, Closure $fail): void { + if (is_array($value) && array_diff(array_keys($value), ['display_name']) !== []) { + $fail('The persona carries a key that is not accepted.'); + } + }], + 'persona.display_name' => ['nullable', new PersonaDisplayName], + ]; + } + + /** + * The `persona` a write leaves behind, as the attribute to merge into the validated payload (empty + * when the request carries no `persona`, which leaves the column untouched). + * + * A write that carries a `persona` changes ONLY `display_name`: every other key already stored (an + * import's) is kept. A blank or absent `display_name` UNSETS the key (never a stored null or empty + * string), and the column becomes null when nothing is left. + * + * Read from the request, not from `validate()`'s result: Laravel drops an EMPTY array from the + * validated data, and `persona: {}` is the payload that clears the name. The name itself was + * validated by `PersonaDisplayName` before this runs. + * + * @param array|null $current the persona already stored + * @return array{persona?: array|null} + */ + private function personaWrite(Request $request, ?array $current): array + { + if (! $request->has('persona')) { + return []; + } + + $persona = $current ?? []; + $name = $request->input('persona.display_name'); + + if (is_string($name) && trim($name) !== '') { + $persona['display_name'] = trim($name); + } else { + unset($persona['display_name']); + } + + return ['persona' => $persona === [] ? null : $persona]; + } + /** * Whether the caller may set the superadmin-only fields (the external HeyGen * voice). Read from the authenticated user, never from the page or route: the diff --git a/app/Http/Resources/AvatarTemplateResource.php b/app/Http/Resources/AvatarTemplateResource.php index a8053609..87e54b8a 100644 --- a/app/Http/Resources/AvatarTemplateResource.php +++ b/app/Http/Resources/AvatarTemplateResource.php @@ -60,9 +60,12 @@ class AvatarTemplateResource extends JsonResource * warning|null, `code` the stable machine code behind a warning, `synced_at` * the last SUCCESS). Admin-only: classified in `ExposureCatalogue`. * - * @return array{id: int, name: string, description: string|null, provider: string, scope: 'organization'|'platform', config: array, is_active: bool, created_at: string|null, updated_at: string|null, llm_model_id: int|null, llm_credential_id: int|null, llm_sync_status: string|null, llm_synced_at: string|null, llm: array{estimated_cost_usd_per_interview: array{minutes: int, turns: int, usd: float}|null}, pal_sync: array{status: string|null, code: string|null, synced_at: string|null}} + * `persona.display_name` is the interviewer's name the candidate reads on the live screen (null when + * unset or when a stored value fails the write rules). Admin-only: classified in `ExposureCatalogue`. * - * @scramble-return array{id: int, name: string, description: string|null, provider: string, scope: 'organization'|'platform', config: array, is_active: bool, created_at: string|null, updated_at: string|null, llm_model_id: int|null, llm_credential_id: int|null, llm_sync_status: string|null, llm_synced_at: string|null, llm: array{estimated_cost_usd_per_interview: array{minutes: int, turns: int, usd: float}|null}, pal_sync: array{status: string|null, code: string|null, synced_at: string|null}} + * @return array{id: int, name: string, description: string|null, provider: string, scope: 'organization'|'platform', config: array, is_active: bool, created_at: string|null, updated_at: string|null, llm_model_id: int|null, llm_credential_id: int|null, llm_sync_status: string|null, llm_synced_at: string|null, llm: array{estimated_cost_usd_per_interview: array{minutes: int, turns: int, usd: float}|null}, pal_sync: array{status: string|null, code: string|null, synced_at: string|null}, persona: array{display_name: string|null}} + * + * @scramble-return array{id: int, name: string, description: string|null, provider: string, scope: 'organization'|'platform', config: array, is_active: bool, created_at: string|null, updated_at: string|null, llm_model_id: int|null, llm_credential_id: int|null, llm_sync_status: string|null, llm_synced_at: string|null, llm: array{estimated_cost_usd_per_interview: array{minutes: int, turns: int, usd: float}|null}, pal_sync: array{status: string|null, code: string|null, synced_at: string|null}, persona: array{display_name: string|null}} */ public function toArray(Request $request): array { @@ -91,6 +94,8 @@ public function toArray(Request $request): array 'code' => $template->pal_sync_code, 'synced_at' => $template->pal_synced_at?->toIso8601String(), ], + // Only `display_name`, never the raw JSON: keys an import brought in stay off this surface. + 'persona' => ['display_name' => $template->personaDisplayName()], ]; } diff --git a/app/Http/Resources/PlatformAvatarTemplateResource.php b/app/Http/Resources/PlatformAvatarTemplateResource.php index 4be2d379..be1be641 100644 --- a/app/Http/Resources/PlatformAvatarTemplateResource.php +++ b/app/Http/Resources/PlatformAvatarTemplateResource.php @@ -30,9 +30,9 @@ public function __construct(AvatarTemplate $resource, private readonly array $us } /** - * @return array{id: int, name: string, description: string|null, provider: string, scope: 'organization'|'platform', config: array, is_active: bool, created_at: string|null, updated_at: string|null, llm_model_id: int|null, llm_credential_id: int|null, llm_sync_status: string|null, llm_synced_at: string|null, llm: array{estimated_cost_usd_per_interview: array{minutes: int, turns: int, usd: float}|null}, pal_sync: array{status: string|null, code: string|null, synced_at: string|null}, usage: array{organization_count: int, project_count: int}} + * @return array{id: int, name: string, description: string|null, provider: string, scope: 'organization'|'platform', config: array, is_active: bool, created_at: string|null, updated_at: string|null, llm_model_id: int|null, llm_credential_id: int|null, llm_sync_status: string|null, llm_synced_at: string|null, llm: array{estimated_cost_usd_per_interview: array{minutes: int, turns: int, usd: float}|null}, pal_sync: array{status: string|null, code: string|null, synced_at: string|null}, persona: array{display_name: string|null}, usage: array{organization_count: int, project_count: int}} * - * @scramble-return array{id: int, name: string, description: string|null, provider: string, scope: 'organization'|'platform', config: array, is_active: bool, created_at: string|null, updated_at: string|null, llm_model_id: int|null, llm_credential_id: int|null, llm_sync_status: string|null, llm_synced_at: string|null, llm: array{estimated_cost_usd_per_interview: array{minutes: int, turns: int, usd: float}|null}, pal_sync: array{status: string|null, code: string|null, synced_at: string|null}, usage: array{organization_count: int, project_count: int}} + * @scramble-return array{id: int, name: string, description: string|null, provider: string, scope: 'organization'|'platform', config: array, is_active: bool, created_at: string|null, updated_at: string|null, llm_model_id: int|null, llm_credential_id: int|null, llm_sync_status: string|null, llm_synced_at: string|null, llm: array{estimated_cost_usd_per_interview: array{minutes: int, turns: int, usd: float}|null}, pal_sync: array{status: string|null, code: string|null, synced_at: string|null}, persona: array{display_name: string|null}, usage: array{organization_count: int, project_count: int}} */ public function toArray(Request $request): array { diff --git a/app/Models/AvatarTemplate.php b/app/Models/AvatarTemplate.php index 46d6c845..eeffa765 100644 --- a/app/Models/AvatarTemplate.php +++ b/app/Models/AvatarTemplate.php @@ -11,6 +11,7 @@ use App\Exceptions\ConversationLlm\UnsupportedLlmModeException; use App\Exceptions\PlatformTemplateWriteRefusedException; use App\Models\Contracts\AdmitsPlatformRows; +use App\Rules\PersonaDisplayName; use App\Support\AvatarTemplates\GlobalAvatarTemplateUsage; use App\Support\AvatarTemplates\PlatformTemplateContext; use App\Support\Tenancy\TenantResolver; @@ -343,6 +344,18 @@ public function scopeLabel(): AvatarTemplateScope return $this->isPlatform() ? AvatarTemplateScope::Platform : AvatarTemplateScope::Organization; } + /** + * The interviewer's display name the candidate sees, or null when none is set. Re-checked on read + * with the write-side rule, so a value stored before that rule existed (an import) degrades to the + * generic label instead of reaching a screen. + */ + public function personaDisplayName(): ?string + { + $name = $this->persona['display_name'] ?? null; + + return is_string($name) && PersonaDisplayName::problem($name) === null ? trim($name) : null; + } + public function writesAsPlatformRow(): bool { return app(PlatformTemplateContext::class)->active(); diff --git a/app/Rules/PersonaDisplayName.php b/app/Rules/PersonaDisplayName.php new file mode 100644 index 00000000..346374ab --- /dev/null +++ b/app/Rules/PersonaDisplayName.php @@ -0,0 +1,83 @@ +` and control or format characters (no markup, no line breaks); + * - free of an avatar provider's or its infrastructure's name (provider opacity extends to + * operator-authored text). `PROVIDERS` is the one constant to extend when a provider is added. + * + * No message repeats the matched vendor word: an error that echoes it back is itself a leak into + * logs and screenshots. + * + * A blank or absent value is NOT this rule's business: the form's `nullable` and the import's + * normalisation treat it as "not set" before it gets here. + * + * REQ: avatar-templates "A template MAY carry an interviewer display name" + */ +final class PersonaDisplayName implements ValidationRule +{ + /** Longest name that fits the chip and the caption prefix. */ + public const MAX = 60; + + /** + * Case-insensitive, bounded by any non letter or digit (so `_` too): HeyGen, LiveAvatar, Tavus, Daily + * (and daily.co), LiveKit. The two parts of a multi-part name may be split by spaces, `-`, `_` or `.` + * (`Hey-Gen`, `Live_Kit`). The word boundary is deliberate: a camel-case suffix such as `LiveKitBot` + * is accepted, as are look-alikes such as `Dailyn`, `Heygenie`, `Taverna` and `Live Aviator`. + */ + private const PROVIDERS = '/(? self::MAX) { + return 'The display name must be between 1 and '.self::MAX.' characters.'; + } + + // The raw value, not the trimmed one: an edge control character would otherwise be stored. + // `!== 0` so a regex error refuses instead of passing. + if (preg_match('/[<>]|[\p{C}\p{Zl}\p{Zp}]/u', $value) !== 0) { + return 'The display name must be plain text.'; + } + + if (preg_match(self::PROVIDERS, $name) !== 0) { + return 'The display name must not name a technology provider.'; + } + + return null; + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + $problem = self::problem($value); + + if ($problem !== null) { + $fail($problem); + } + } +} diff --git a/app/Support/AvatarTemplates/TemplateDocument.php b/app/Support/AvatarTemplates/TemplateDocument.php index df4ceb75..4b926923 100644 --- a/app/Support/AvatarTemplates/TemplateDocument.php +++ b/app/Support/AvatarTemplates/TemplateDocument.php @@ -71,7 +71,7 @@ public static function flatten(array $entry): array { $name = is_string($entry['name'] ?? null) ? $entry['name'] : ''; $description = is_string($entry['description'] ?? null) ? $entry['description'] : null; - $persona = is_array($entry['persona'] ?? null) ? $entry['persona'] : null; + $persona = self::normalizePersona(is_array($entry['persona'] ?? null) ? $entry['persona'] : null); // Single-provider shape: BEAI's own export. if (isset($entry['provider'])) { @@ -110,6 +110,32 @@ public static function flatten(array $entry): array return $records; } + /** + * Normalises the persona the way the form does: a string `display_name` is trimmed, and a blank + * or null one is UNSET (never stored as an empty string), leaving null when nothing remains. + * Anything else is left for `PersonaDisplayName` to refuse. The rule itself runs in the import + * controller, so a file can never install a name the form would reject. + * + * @param array|null $persona + * @return array|null + */ + private static function normalizePersona(?array $persona): ?array + { + if ($persona === null || ! array_key_exists('display_name', $persona)) { + return $persona; + } + + $name = is_string($persona['display_name']) ? trim($persona['display_name']) : $persona['display_name']; + + if ($name === null || $name === '') { + unset($persona['display_name']); + } else { + $persona['display_name'] = $name; + } + + return $persona === [] ? null : $persona; + } + /** * @return array{model_key: string, credential_name: string}|null */ diff --git a/app/Support/Interview/CompetencyProgress.php b/app/Support/Interview/CompetencyProgress.php new file mode 100644 index 00000000..2e44694b --- /dev/null +++ b/app/Support/Interview/CompetencyProgress.php @@ -0,0 +1,112 @@ + + */ + public function ordered(int $projectId): Collection + { + return DB::table('project_competencies as pc') + ->join('framework_competencies as fc', 'fc.id', '=', 'pc.competency_id') + ->where('pc.project_id', $projectId) + ->orderBy('pc.position') + ->select('fc.id as competency_id', 'fc.code as competency_code', 'pc.position') + ->get(); + } + + /** + * Whether the competency still has to be (re)asked: no session yet, a pending or + * in_corso session (resume), or an errored one with attempts left (re-offer). + * completed | timeout | skipped | an exhausted error are closed. + */ + public static function isOpen(?InterviewSession $session): bool + { + if ($session === null) { + return true; + } + + if (in_array($session->status, ['pending', 'in_corso'], true)) { + return true; + } + + return $session->status === 'error' && $session->error_count < InterviewSession::MAX_ERROR_ATTEMPTS; + } + + /** + * The next competency for this participant, or null when all are closed. + * + * @return array{competency_code: string, question_index: int, competency_ordinal: int, total_competencies: int, reoffer?: bool}|null + */ + public function resolveNext(int $participantId, int $projectId): ?array + { + $all = $this->ordered($projectId); + + $existing = InterviewSession::where('participant_id', $participantId) + ->where('project_id', $projectId) + ->get(['competency_code', 'status', 'error_count']) + ->keyBy('competency_code'); + + $total = $all->count(); + + foreach ($all->values() as $index => $row) { + $session = $existing->get($row->competency_code); + + if (! self::isOpen($session)) { + continue; + } + + $payload = [ + 'competency_code' => (string) $row->competency_code, + 'question_index' => (int) $row->position, + 'competency_ordinal' => $index + 1, + 'total_competencies' => $total, + ]; + + // Only an errored session is a re-offer; a missing or pending/in_corso one is a plain (re)start. + if ($session?->status === 'error') { + $payload['reoffer'] = true; + } + + return $payload; + } + + return null; + } +} diff --git a/openapi.json b/openapi.json index d03616ad..b3a19221 100644 --- a/openapi.json +++ b/openapi.json @@ -1748,6 +1748,17 @@ "integer", "null" ] + }, + "persona": { + "type": "object", + "properties": { + "display_name": { + "type": [ + "string", + "null" + ] + } + } } }, "required": [ @@ -1883,6 +1894,17 @@ "integer", "null" ] + }, + "persona": { + "type": "object", + "properties": { + "display_name": { + "type": [ + "string", + "null" + ] + } + } } } } @@ -7510,6 +7532,144 @@ } } }, + "/candidate/interview/outline": { + "get": { + "operationId": "outline.show", + "description": "Returns the interview as the candidate sees it while it runs: the assessment type, the role, the\ninterviewer's and the candidate's own display names, and the competencies in order, each with its\nname, its 1-based position, how many scripted questions it holds and whether it is done, current,\nupcoming or queued to be repeated. Names are in the project language. No competency code or\nidentifier is returned. `422 assessment_type_not_supported` means the project's assessment type is\nnot supported, and `422 project_not_found` means the project no longer exists.", + "summary": "The candidate's interview path", + "tags": [ + "Outline" + ], + "responses": { + "200": { + "description": "The interview outline.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "assessment_type": { + "type": "string", + "enum": [ + "standard", + "potential" + ] + }, + "role_name": { + "type": [ + "string", + "null" + ] + }, + "avatar_name": { + "type": [ + "string", + "null" + ] + }, + "candidate_name": { + "type": [ + "string", + "null" + ] + }, + "primary_total_all": { + "type": "integer" + }, + "competencies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "ordinal": { + "type": "integer" + }, + "status": { + "type": "string", + "enum": [ + "done", + "current", + "upcoming", + "retry" + ] + }, + "primary_total": { + "type": "integer" + } + }, + "required": [ + "name", + "ordinal", + "status", + "primary_total" + ] + } + } + }, + "required": [ + "assessment_type", + "role_name", + "avatar_name", + "candidate_name", + "primary_total_all", + "competencies" + ] + } + } + } + }, + "422": { + "description": "The project no longer exists (`project_not_found`) or its assessment type is not supported (`assessment_type_not_supported`).", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": [ + "project_not_found", + "assessment_type_not_supported" + ] + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + }, + "403": { + "description": "The participant is no longer in an active interview (completed or errored).", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + } + } + } + }, "/participants": { "get": { "operationId": "participant.index", @@ -8855,6 +9015,17 @@ "integer", "null" ] + }, + "persona": { + "type": "object", + "properties": { + "display_name": { + "type": [ + "string", + "null" + ] + } + } } }, "required": [ @@ -8993,6 +9164,17 @@ "integer", "null" ] + }, + "persona": { + "type": "object", + "properties": { + "display_name": { + "type": [ + "string", + "null" + ] + } + } } } } @@ -13709,7 +13891,7 @@ }, "responses": { "202": { - "description": "Utterance persisted. `boundary_due` is true once the competency has met its turn budget.", + "description": "Utterance persisted. `boundary_due` is true once the competency has met its turn budget. `primary_index` is how many distinct scripted questions of this competency have been asked so far.", "content": { "application/json": { "schema": { @@ -13717,10 +13899,14 @@ "properties": { "boundary_due": { "type": "boolean" + }, + "primary_index": { + "type": "integer" } }, "required": [ - "boundary_due" + "boundary_due", + "primary_index" ] } } @@ -14595,6 +14781,20 @@ "code", "synced_at" ] + }, + "persona": { + "type": "object", + "properties": { + "display_name": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "display_name" + ] } }, "required": [ @@ -14612,7 +14812,8 @@ "llm_sync_status", "llm_synced_at", "llm", - "pal_sync" + "pal_sync", + "persona" ], "title": "AvatarTemplateResource" }, @@ -16398,6 +16599,20 @@ "synced_at" ] }, + "persona": { + "type": "object", + "properties": { + "display_name": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "display_name" + ] + }, "usage": { "type": "object", "properties": { @@ -16430,6 +16645,7 @@ "llm_synced_at", "llm", "pal_sync", + "persona", "usage" ], "title": "PlatformAvatarTemplateResource" diff --git a/routes/api.php b/routes/api.php index 72f11a92..7f7fb65a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -48,6 +48,7 @@ use App\Http\Controllers\AvatarVoicePreviewController; use App\Http\Controllers\Candidate\IntegrityController; use App\Http\Controllers\Candidate\InterviewController; +use App\Http\Controllers\Candidate\OutlineController; use App\Http\Controllers\Candidate\SessionController; use App\Http\Controllers\Candidate\SnapshotController; use App\Http\Controllers\Candidate\UtteranceController; @@ -950,6 +951,10 @@ // POST /api/candidate/interview/snapshot — JPEG snapshot to S3 Route::post('/snapshot', [SnapshotController::class, 'store']); + + // GET /api/candidate/interview/outline — read-only, named view of the interview path + // (live-interview-call-chrome). No identifier: the participant comes from the token. + Route::get('/outline', [OutlineController::class, 'show']); }); }); diff --git a/tests/Feature/AvatarTemplates/PersonaDisplayNameWriteTest.php b/tests/Feature/AvatarTemplates/PersonaDisplayNameWriteTest.php new file mode 100644 index 00000000..eecf1283 --- /dev/null +++ b/tests/Feature/AvatarTemplates/PersonaDisplayNameWriteTest.php @@ -0,0 +1,213 @@ +create(); + + return $surface === 'organization' + ? ['/api/avatar-templates', TemplateActors::token('acting', $org), $org] + : ['/api/admin/avatar-templates', TemplateActors::token('bare', $org), $org]; +} + +/** + * @param array|null $persona + */ +function pdnSeed(string $surface, Organization $org, ?array $persona): AvatarTemplate +{ + if ($surface === 'platform') { + return PlatformTemplates::insertGlobal(['persona' => $persona === null ? null : json_encode($persona)]); + } + + return TenantContextScope::runFor($org->id, fn (): AvatarTemplate => AvatarTemplate::create([ + 'name' => 'Seed '.uniqid(), + 'provider' => 'heygen', + 'config' => ['avatarId' => 'av', 'voiceId' => 'vo'], + 'persona' => $persona, + ])); +} + +/** + * @param array $extra + * @return array + */ +function pdnCreatePayload(array $extra = []): array +{ + return array_merge([ + 'name' => 'Persona '.uniqid(), + 'provider' => 'heygen', + 'config' => ['avatarId' => 'av_1', 'voiceId' => 'vo_1'], + ], $extra); +} + +/** @return array|null */ +function pdnStoredPersona(int $id): ?array +{ + $persona = AvatarTemplate::withoutGlobalScopes()->findOrFail($id)->persona; + + // jsonb keeps keys in its own order; the keys are what is asserted, not their order. + if ($persona !== null) { + ksort($persona); + } + + return $persona; +} + +function pdnTemplateCount(): int +{ + return AvatarTemplate::withoutGlobalScopes()->count(); +} + +// ─── Refusals (create and update) ──────────────────────────────────────────── + +test('an invalid persona is refused on create and nothing is written', function (string $surface, array $persona, string $errorKey): void { + [$url, $token] = pdnSurface($surface); + $before = pdnTemplateCount(); + + $this->withToken($token)->postJson($url, pdnCreatePayload(['persona' => $persona])) + ->assertUnprocessable() + ->assertJsonValidationErrors([$errorKey]); + + expect(pdnTemplateCount())->toBe($before); +})->with('pdnSurfaces')->with([ + 'too long' => [['display_name' => str_repeat('a', 61)], 'persona.display_name'], + 'markup' => [['display_name' => 'Giulia'], 'persona.display_name'], + 'control character' => [['display_name' => "Giu\x07lia"], 'persona.display_name'], + 'provider name' => [['display_name' => 'Tavus Coach'], 'persona.display_name'], + 'an unknown persona key' => [['display_name' => 'Giulia', 'voice' => 'x'], 'persona'], +]); + +test('an invalid persona is refused on update and the stored persona is unchanged', function (string $surface, array $persona, string $errorKey): void { + [$url, $token, $org] = pdnSurface($surface); + $template = pdnSeed($surface, $org, ['display_name' => 'Giulia']); + + $this->withToken($token)->patchJson("{$url}/{$template->id}", ['persona' => $persona]) + ->assertUnprocessable() + ->assertJsonValidationErrors([$errorKey]); + + expect(pdnStoredPersona($template->id))->toBe(['display_name' => 'Giulia']); +})->with('pdnSurfaces')->with([ + 'too long' => [['display_name' => str_repeat('a', 61)], 'persona.display_name'], + 'markup' => [['display_name' => 'Gi>ulia'], 'persona.display_name'], + 'provider name' => [['display_name' => 'My LiveKit agent'], 'persona.display_name'], + 'an unknown persona key' => [['display_name' => 'Giulia', 'voice' => 'x'], 'persona'], +]); + +test('a null persona is refused on create and update, while an empty object stays the clear', function (string $surface): void { + [$url, $token, $org] = pdnSurface($surface); + $template = pdnSeed($surface, $org, ['display_name' => 'Giulia']); + + $this->withToken($token)->postJson($url, pdnCreatePayload(['persona' => null])) + ->assertUnprocessable()->assertJsonValidationErrors(['persona']); + $this->withToken($token)->patchJson("{$url}/{$template->id}", ['persona' => null]) + ->assertUnprocessable()->assertJsonValidationErrors(['persona']); + + expect(pdnStoredPersona($template->id))->toBe(['display_name' => 'Giulia']); + + $this->withToken($token)->patchJson("{$url}/{$template->id}", ['persona' => []])->assertOk(); + + expect(pdnStoredPersona($template->id))->toBeNull(); +})->with('pdnSurfaces'); + +test('the refusal never repeats the vendor name', function (string $surface): void { + [$url, $token] = pdnSurface($surface); + + $response = $this->withToken($token)->postJson($url, pdnCreatePayload(['persona' => ['display_name' => 'Tavus Coach']])) + ->assertUnprocessable(); + + expect(mb_strtolower($response->getContent()))->not->toContain('tavus'); +})->with('pdnSurfaces'); + +test('a persona error is listed together with another invalid field', function (string $surface): void { + [$url, $token] = pdnSurface($surface); + + $this->withToken($token)->postJson($url, pdnCreatePayload([ + 'provider' => 'nope', + 'persona' => ['display_name' => ''], + ]))->assertUnprocessable()->assertJsonValidationErrors(['provider', 'persona.display_name']); +})->with('pdnSurfaces'); + +// ─── Accepted writes ───────────────────────────────────────────────────────── + +test('a valid name is trimmed and stored on create and on update', function (string $surface): void { + [$url, $token, $org] = pdnSurface($surface); + + $created = $this->withToken($token)->postJson($url, pdnCreatePayload(['persona' => ['display_name' => ' Giulia ']])) + ->assertCreated(); + $id = (int) $created->json('data.id'); + expect(pdnStoredPersona($id))->toBe(['display_name' => 'Giulia']); + + $this->withToken($token)->patchJson("{$url}/{$id}", ['persona' => ['display_name' => ' Marco ']])->assertOk(); + expect(pdnStoredPersona($id))->toBe(['display_name' => 'Marco']); +})->with('pdnSurfaces'); + +test('a create without a persona stores none', function (string $surface): void { + [$url, $token] = pdnSurface($surface); + + $id = (int) $this->withToken($token)->postJson($url, pdnCreatePayload())->assertCreated()->json('data.id'); + + expect(pdnStoredPersona($id))->toBeNull(); +})->with('pdnSurfaces'); + +// ─── Merge semantics ───────────────────────────────────────────────────────── + +test('an update without a persona leaves the column untouched', function (string $surface): void { + [$url, $token, $org] = pdnSurface($surface); + $template = pdnSeed($surface, $org, ['display_name' => 'Giulia', 'imported' => 'kept']); + + $this->withToken($token)->patchJson("{$url}/{$template->id}", ['description' => 'Only the description'])->assertOk(); + + expect(pdnStoredPersona($template->id))->toBe(['display_name' => 'Giulia', 'imported' => 'kept']); +})->with('pdnSurfaces'); + +test('a new name keeps the other persona keys an import brought in', function (string $surface): void { + [$url, $token, $org] = pdnSurface($surface); + $template = pdnSeed($surface, $org, ['display_name' => 'Giulia', 'imported' => 'kept']); + + $this->withToken($token)->patchJson("{$url}/{$template->id}", ['persona' => ['display_name' => 'Marco']])->assertOk(); + + expect(pdnStoredPersona($template->id))->toBe(['display_name' => 'Marco', 'imported' => 'kept']); +})->with('pdnSurfaces'); + +test('clearing the name unsets the key, never storing null or an empty string', function (string $surface, array $persona): void { + [$url, $token, $org] = pdnSurface($surface); + $withExtra = pdnSeed($surface, $org, ['display_name' => 'Giulia', 'imported' => 'kept']); + $alone = pdnSeed($surface, $org, ['display_name' => 'Giulia']); + + $this->withToken($token)->patchJson("{$url}/{$withExtra->id}", ['persona' => $persona])->assertOk(); + $this->withToken($token)->patchJson("{$url}/{$alone->id}", ['persona' => $persona])->assertOk(); + + expect(pdnStoredPersona($withExtra->id))->toBe(['imported' => 'kept']) + ->and(pdnStoredPersona($alone->id))->toBeNull(); +})->with('pdnSurfaces')->with([ + 'an empty persona object' => [[]], + 'a null display name' => [['display_name' => null]], + 'a blank display name' => [['display_name' => ' ']], +]); diff --git a/tests/Feature/AvatarTemplates/PersonaImportTest.php b/tests/Feature/AvatarTemplates/PersonaImportTest.php new file mode 100644 index 00000000..710a42c9 --- /dev/null +++ b/tests/Feature/AvatarTemplates/PersonaImportTest.php @@ -0,0 +1,135 @@ + $persona + * @return array{schema: string, templates: list>} + */ +function pimDocument(array $persona, string $name = 'Imported'): array +{ + return [ + 'schema' => 'beai.avatar-template/1', + 'templates' => [[ + 'name' => $name, + 'provider' => 'heygen', + 'config' => ['avatarId' => 'av_1', 'voiceId' => 'vo_1'], + 'persona' => $persona, + ]], + ]; +} + +test('an invalid persona name fails the import with a persona.display_name error and creates nothing', function (string $name): void { + $org = Organization::factory()->create(); + $token = TemplateActors::token('acting', $org); + $before = AvatarTemplate::withoutGlobalScopes()->count(); + + $response = $this->withToken($token)->postJson('/api/avatar-templates/import', pimDocument(['display_name' => $name])) + ->assertUnprocessable(); + + expect($response->json('errors')['templates.0'][0])->toStartWith('persona.display_name: ') + ->and(AvatarTemplate::withoutGlobalScopes()->count())->toBe($before); +})->with([ + 'a provider name' => ['Tavus'], + 'too long' => [str_repeat('a', 61)], + 'markup' => [''], +]); + +test('a display name that is not text fails the import cleanly and creates nothing', function (mixed $name): void { + $token = TemplateActors::token('acting', Organization::factory()->create()); + $before = AvatarTemplate::withoutGlobalScopes()->count(); + + $response = $this->withToken($token)->postJson('/api/avatar-templates/import', pimDocument(['display_name' => $name])) + ->assertUnprocessable(); + + expect($response->json('errors')['templates.0'][0])->toStartWith('persona.display_name: ') + ->and(AvatarTemplate::withoutGlobalScopes()->count())->toBe($before); +})->with([[123], [['Giulia']], [true]]); + +test('the refusal does not repeat the vendor name', function (): void { + $token = TemplateActors::token('acting', Organization::factory()->create()); + + $response = $this->withToken($token)->postJson('/api/avatar-templates/import', pimDocument(['display_name' => 'Tavus'])) + ->assertUnprocessable(); + + expect(mb_strtolower($response->getContent()))->not->toContain('tavus'); +}); + +test('one bad entry fails the whole document', function (): void { + $org = Organization::factory()->create(); + $token = TemplateActors::token('acting', $org); + $document = pimDocument(['display_name' => 'Giulia'], 'Good'); + $document['templates'][] = pimDocument(['display_name' => 'Tavus'], 'Bad')['templates'][0]; + + $this->withToken($token)->postJson('/api/avatar-templates/import', $document) + ->assertUnprocessable()->assertJsonValidationErrors(['templates.1']); + + expect(AvatarTemplate::withoutGlobalScopes()->where('name', 'Good')->exists())->toBeFalse(); +}); + +test('a blank name is dropped on import and a valid one is trimmed', function (array $persona, ?array $expected): void { + $org = Organization::factory()->create(); + $token = TemplateActors::token('acting', $org); + + $this->withToken($token)->postJson('/api/avatar-templates/import', pimDocument($persona))->assertCreated(); + + $stored = AvatarTemplate::withoutGlobalScopes()->where('name', 'Imported')->firstOrFail()->persona; + expect($stored)->toBe($expected); +})->with([ + 'a blank name' => [['display_name' => ' '], null], + 'a null name' => [['display_name' => null], null], + 'a blank name beside another key' => [['display_name' => '', 'imported' => 'kept'], ['imported' => 'kept']], + 'a padded name' => [['display_name' => ' Giulia '], ['display_name' => 'Giulia']], +]); + +test('export then import round-trips the name as an inactive template', function (): void { + $org = Organization::factory()->create(); + $token = TemplateActors::token('acting', $org); + TenantContextScope::runFor($org->id, fn () => AvatarTemplate::create([ + 'name' => 'Source', 'provider' => 'heygen', 'config' => ['avatarId' => 'av_1', 'voiceId' => 'vo_1'], + 'persona' => ['display_name' => 'Giulia'], + ])); + + $document = $this->withToken($token)->getJson('/api/avatar-templates/export')->assertOk()->json(); + $document['templates'][0]['name'] = 'Round trip'; + $this->withToken($token)->postJson('/api/avatar-templates/import', $document)->assertCreated(); + + $copy = AvatarTemplate::withoutGlobalScopes()->where('name', 'Round trip')->firstOrFail(); + expect($copy->persona)->toBe(['display_name' => 'Giulia']) + ->and($copy->is_active)->toBeFalse(); +}); + +test('duplicating into another organization keeps the name and leaves the original unchanged', function (): void { + $source = Organization::factory()->create(); + $target = Organization::factory()->create(); + $original = TenantContextScope::runFor($source->id, fn () => AvatarTemplate::create([ + 'name' => 'Original', 'provider' => 'heygen', 'config' => ['avatarId' => 'av_1', 'voiceId' => 'vo_1'], + 'persona' => ['display_name' => 'Giulia'], + ])); + + $this->withToken(TemplateActors::token('bare', $source)) + ->postJson("/api/avatar-templates/{$original->id}/duplicate", ['target_organization_ids' => [$target->id]]) + ->assertCreated(); + + $copy = TenantContextScope::runFor($target->id, fn () => AvatarTemplate::query()->firstOrFail()); + expect($copy->persona)->toBe(['display_name' => 'Giulia']) + ->and(AvatarTemplate::withoutGlobalScopes()->findOrFail($original->id)->persona)->toBe(['display_name' => 'Giulia']); +}); diff --git a/tests/Feature/AvatarTemplates/PersonaResourceTest.php b/tests/Feature/AvatarTemplates/PersonaResourceTest.php new file mode 100644 index 00000000..50d404f3 --- /dev/null +++ b/tests/Feature/AvatarTemplates/PersonaResourceTest.php @@ -0,0 +1,114 @@ +|null $persona + * @return array{0: string, 1: string} the template URL and the bearer + */ +function prTemplateUrl(string $surface, ?array $persona): array +{ + $org = Organization::factory()->create(); + + if ($surface === 'platform') { + $template = PlatformTemplates::insertGlobal(['persona' => $persona === null ? null : json_encode($persona)]); + + return ["/api/admin/avatar-templates/{$template->id}", TemplateActors::token('bare', $org)]; + } + + $template = TenantContextScope::runFor($org->id, fn (): AvatarTemplate => AvatarTemplate::create([ + 'name' => 'Resource '.uniqid(), + 'provider' => 'heygen', + 'config' => ['avatarId' => 'av', 'voiceId' => 'vo'], + 'persona' => $persona, + ])); + + return ["/api/avatar-templates/{$template->id}", TemplateActors::token('acting', $org)]; +} + +test('the resource returns display_name and no other persona key', function (string $surface): void { + [$url, $token] = prTemplateUrl($surface, ['display_name' => 'Giulia', 'imported' => 'secret-ish']); + + $response = $this->withToken($token)->getJson($url)->assertOk(); + + $response->assertJsonPath('data.persona', ['display_name' => 'Giulia']); + expect($response->getContent())->not->toContain('imported'); +})->with('prSurfaces'); + +test('a template with no persona returns a null display_name', function (string $surface, ?array $persona): void { + [$url, $token] = prTemplateUrl($surface, $persona); + + $this->withToken($token)->getJson($url)->assertOk()->assertJsonPath('data.persona', ['display_name' => null]); +})->with('prSurfaces')->with([ + 'no persona at all' => [null], + 'a persona without a name' => [['imported' => 'x']], +]); + +test('a stored legacy value no write path would accept degrades to null', function (string $surface, string $stored): void { + [$url, $token] = prTemplateUrl($surface, ['display_name' => $stored]); + + $this->withToken($token)->getJson($url)->assertOk()->assertJsonPath('data.persona.display_name', null); +})->with('prSurfaces')->with([ + 'too long' => [str_repeat('a', 61)], + 'markup' => ['Giulia'], + 'a provider name' => ['Tavus Coach'], + 'blank' => [' '], +]); + +test('the accessor trims a valid stored name', function (): void { + $template = new AvatarTemplate(['persona' => ['display_name' => ' Giulia ']]); + + expect($template->personaDisplayName())->toBe('Giulia'); +}); + +test('the accessor is null for an absent persona and for a non-string name', function (?array $persona): void { + expect((new AvatarTemplate(['persona' => $persona]))->personaDisplayName())->toBeNull(); +})->with([[null], [[]], [['display_name' => 42]]]); + +test('no avatar provider request carries the display name', function (): void { + Http::fake(heygenOkFake()); + Queue::fake(); + + $org = casOrg(); + [$project] = casProject($org, 1); + AvatarTemplate::withoutGlobalScopes()->findOrFail($project->avatar_template_id)->update(['persona' => ['display_name' => 'Giulia Marker']]); + $participant = casParticipant($org, $project, 'in_attesa'); + + $this->withHeaders(['Authorization' => 'Bearer '.casBearer($participant)]) + ->postJson('/api/candidate/interview/start') + ->assertStatus(201); + + $recorded = Http::recorded(); + expect($recorded)->not->toBeEmpty(); + foreach ($recorded as [$request]) { + /** @var HttpRequest $request */ + expect($request->body().json_encode($request->headers()))->not->toContain('Giulia Marker'); + } +}); diff --git a/tests/Feature/C7a/UtterancePrimaryIndexTest.php b/tests/Feature/C7a/UtterancePrimaryIndexTest.php new file mode 100644 index 00000000..515e0307 --- /dev/null +++ b/tests/Feature/C7a/UtterancePrimaryIndexTest.php @@ -0,0 +1,163 @@ +|null $primaries + * @return array{0: InterviewSession, 1: string, 2: Participant} + */ +function pixSession(?array $primaries = PIX_PRIMARIES, string $status = 'in_corso', ?Participant $participant = null, string $code = 'PRS'): array +{ + if ($participant === null) { + $org = Organization::factory()->create(); + $resolver = app(TenantResolver::class); + $resolver->setOrgId($org->id); + $resolver->setBypass(false); + + $project = Project::factory()->create(['status' => 'active']); + $participant = new Participant; + $participant->forceFill([ + 'organization_id' => $org->id, + 'project_id' => $project->id, + 'candidate_ref' => 'pix-'.uniqid(), + 'display_name' => 'Primary Index', + 'email' => uniqid('pix-').'@example.test', + 'status' => 'in_corso', + ])->save(); + $participant = $participant->fresh(); + } + + $session = InterviewSession::create([ + 'participant_id' => $participant->id, + 'project_id' => $participant->project_id, + 'question_index' => 0, + 'competency_code' => $code, + 'framework_version_id' => Project::findOrFail($participant->project_id)->framework_version_id, + 'provider' => 'tavus', + 'status' => $status, + 'primary_questions' => $primaries, + ]); + + return [$session, CandidateTokenFactory::mintCandidateToken($participant), $participant]; +} + +function pixPost(mixed $test, InterviewSession $session, string $token, string $speaker, string $text): TestResponse +{ + return $test->withHeaders(['Authorization' => 'Bearer '.$token]) + ->postJson('/api/candidate/interview/utterance', [ + 'session_id' => $session->id, + 'speaker' => $speaker, + 'text' => $text, + 'ts' => now()->toIso8601String(), + ]); +} + +test('the first scripted primary yields 1 and the next distinct one yields 2', function (): void { + [$session, $token] = pixSession(); + + pixPost($this, $session, $token, 'avatar', PIX_PRIMARIES[0])->assertStatus(202)->assertJsonPath('primary_index', 1); + pixPost($this, $session, $token, 'avatar', PIX_PRIMARIES[1])->assertStatus(202)->assertJsonPath('primary_index', 2); +}); + +test('a follow-up and a candidate turn do not advance primary_index', function (): void { + [$session, $token] = pixSession(); + pixPost($this, $session, $token, 'avatar', PIX_PRIMARIES[0])->assertJsonPath('primary_index', 1); + + pixPost($this, $session, $token, 'avatar', 'Interesting, could you say more about that?') + ->assertStatus(202)->assertJsonPath('primary_index', 1); + pixPost($this, $session, $token, 'candidate', 'I led a migration of our billing system last year.') + ->assertStatus(202)->assertJsonPath('primary_index', 1); +}); + +test('a verbatim re-ask does not advance primary_index and the next distinct primary yields 3', function (): void { + [$session, $token] = pixSession(); + pixPost($this, $session, $token, 'avatar', PIX_PRIMARIES[0]); + pixPost($this, $session, $token, 'avatar', PIX_PRIMARIES[1])->assertJsonPath('primary_index', 2); + + pixPost($this, $session, $token, 'avatar', 'Let me repeat. '.PIX_PRIMARIES[1]) + ->assertStatus(202)->assertJsonPath('primary_index', 2); + pixPost($this, $session, $token, 'avatar', PIX_PRIMARIES[2]) + ->assertStatus(202)->assertJsonPath('primary_index', 3); +}); + +test('primary_index is 0 before any scripted primary', function (): void { + [$session, $token] = pixSession(); + + pixPost($this, $session, $token, 'candidate', 'Hello, can you hear me?') + ->assertStatus(202)->assertJsonPath('primary_index', 0); +}); + +test('a competency whose frozen set holds no primaries always returns 0', function (?array $primaries): void { + [$session, $token] = pixSession($primaries); + + pixPost($this, $session, $token, 'avatar', PIX_PRIMARIES[0])->assertStatus(202)->assertJsonPath('primary_index', 0); + pixPost($this, $session, $token, 'candidate', 'An answer.')->assertStatus(202)->assertJsonPath('primary_index', 0); +})->with([[null], [[]]]); + +test('the count restarts at 1 after a re-offer discards the transcript', function (): void { + [$session, $token] = pixSession(); + pixPost($this, $session, $token, 'avatar', PIX_PRIMARIES[0]); + pixPost($this, $session, $token, 'avatar', PIX_PRIMARIES[1])->assertJsonPath('primary_index', 2); + + // What `ResetSessionForRetry` does to the transcript of a re-offered competency. + Utterance::where('interview_session_id', $session->id)->delete(); + + pixPost($this, $session, $token, 'avatar', PIX_PRIMARIES[0])->assertStatus(202)->assertJsonPath('primary_index', 1); +}); + +test('primary_index is scoped to its own competency session', function (): void { + [$first, $token, $participant] = pixSession(); + foreach (PIX_PRIMARIES as $primary) { + pixPost($this, $first, $token, 'avatar', $primary); + } + [$second, $secondToken] = pixSession(['Describe a deadline you missed.'], participant: $participant, code: 'COL'); + + pixPost($this, $second, $secondToken, 'avatar', 'Describe a deadline you missed.') + ->assertStatus(202)->assertJsonPath('primary_index', 1); + pixPost($this, $first, $token, 'candidate', 'Still answering the first.')->assertJsonPath('primary_index', 3); +}); + +test('a dropped utterance still answers 409 with its original body and no primary_index', function (): void { + [$session, $token] = pixSession(status: 'completed'); + + pixPost($this, $session, $token, 'avatar', PIX_PRIMARIES[0]) + ->assertStatus(409) + ->assertExactJson(['message' => 'Session is no longer in_corso.']); +}); + +test('the stored question_asked event keeps its 0-based index', function (): void { + [$session, $token, $participant] = pixSession(); + pixPost($this, $session, $token, 'avatar', PIX_PRIMARIES[0]); + pixPost($this, $session, $token, 'avatar', PIX_PRIMARIES[1])->assertJsonPath('primary_index', 2); + + $indices = TenantContextScope::runFor($session->organization_id, fn () => InterviewEvent::where('participant_id', $participant->id) + ->where('type', 'question_asked')->orderBy('id')->get()->map(fn ($e) => $e->data['question_index'])->all()); + + expect($indices)->toBe([0, 1]); +}); diff --git a/tests/Feature/Candidate/InterviewOutlineTest.php b/tests/Feature/Candidate/InterviewOutlineTest.php new file mode 100644 index 00000000..42837aa0 --- /dev/null +++ b/tests/Feature/Candidate/InterviewOutlineTest.php @@ -0,0 +1,376 @@ +, 3: Participant} + */ +function olnWorld(int $count = 3, string $language = 'en'): array +{ + $org = casOrg(); + [$project, $competencies] = casProject($org, $count); + + $project->update(['language' => $language]); + + foreach ($competencies as $i => $competency) { + $competency->setTranslations('name', ['en' => 'Skill '.($i + 1), 'it' => 'Competenza '.($i + 1)])->save(); + } + + $participant = casParticipant($org, $project, 'in_corso'); + + return [$org, $project, $competencies, $participant]; +} + +function olnGet(Participant $participant, array $headers = [], string $query = ''): TestResponse +{ + // The guard keeps the first request's user for the life of the app: a second candidate in the same test must start clean. + app('auth')->forgetGuards(); + + return test()->withHeaders(['Authorization' => 'Bearer '.casBearer($participant)] + $headers) + ->getJson(OLN_URL.$query); +} + +function olnSession(Participant $participant, Project $project, Competency $competency, string $status, int $errorCount = 0, ?array $primaries = null): InterviewSession +{ + $session = InterviewSession::create([ + 'participant_id' => $participant->id, + 'project_id' => $project->id, + 'question_index' => 0, + 'competency_code' => $competency->code, + 'framework_version_id' => $project->framework_version_id, + 'provider' => 'fake', + 'provider_session_ref' => 'ref-'.uniqid(), + 'status' => $status, + 'primary_questions' => $primaries, + ]); + + DB::table('interview_sessions')->where('id', $session->id)->update(['error_count' => $errorCount]); + + return $session; +} + +function olnAddPrimaries(Project $project, Competency $competency, int $extra): void +{ + for ($i = 0; $i < $extra; $i++) { + ProjectQuestion::create([ + 'project_id' => $project->id, + 'competency_id' => $competency->id, + 'text' => ['en' => "Extra question {$i}", 'it' => "Domanda extra {$i}"], + 'position' => $i + 1, + ]); + } +} + +function olnTemplate(?string $displayName): AvatarTemplate +{ + return AvatarTemplate::create([ + 'name' => 'Outline template '.uniqid(), + 'provider' => 'heygen', + 'config' => [], + 'persona' => $displayName === null ? null : ['display_name' => $displayName], + ]); +} + +/** @return list */ +function olnStatuses(TestResponse $response): array +{ + return array_column($response->json('competencies'), 'status'); +} + +// ─── Shape, names and the persona ───────────────────────────────────────────── + +test('a standard interview returns role, avatar, candidate and ordered competencies and nothing else', function (): void { + [, $project, $competencies, $participant] = olnWorld(3, 'it'); + Role::where('code', $project->role_code)->firstOrFail() + ->setTranslations('name', ['en' => 'Sales Lead', 'it' => 'Responsabile Vendite'])->save(); + olnAddPrimaries($project, $competencies[0], 1); + olnAddPrimaries($project, $competencies[1], 2); + $project->update(['avatar_template_id' => olnTemplate('Giulia')->id]); + DB::table('participants')->where('id', $participant->id)->update(['display_name' => ' Marco Rossi ']); + + $response = olnGet($participant)->assertOk(); + + expect(array_keys($response->json()))->toEqualCanonicalizing( + ['assessment_type', 'role_name', 'avatar_name', 'candidate_name', 'competencies', 'primary_total_all'], + ); + $response->assertJsonPath('assessment_type', 'standard') + ->assertJsonPath('role_name', 'Responsabile Vendite') + ->assertJsonPath('avatar_name', 'Giulia') + ->assertJsonPath('candidate_name', 'Marco Rossi') + ->assertJsonPath('primary_total_all', 6); + expect($response->json('competencies'))->toBe([ + ['name' => 'Competenza 1', 'ordinal' => 1, 'status' => 'current', 'primary_total' => 2], + ['name' => 'Competenza 2', 'ordinal' => 2, 'status' => 'upcoming', 'primary_total' => 3], + ['name' => 'Competenza 3', 'ordinal' => 3, 'status' => 'upcoming', 'primary_total' => 1], + ]); + foreach ($competencies as $competency) { + expect($response->getContent())->not->toContain($competency->code); + } +}); + +test('a potential assessment has no role name and lists the potential competencies', function (): void { + $org = casOrg(); + casProject($org, 1); // seeds the tenant context and the catalogue role + $project = Project::factory()->potential()->create(['status' => 'active']); + $competency = Competency::factory()->potential()->create(['name' => ['en' => 'Mastery']]); + DB::table('project_competencies')->insert(['project_id' => $project->id, 'competency_id' => $competency->id, 'position' => 0]); + ProjectQuestion::create(['project_id' => $project->id, 'competency_id' => $competency->id, 'text' => ['en' => 'Q'], 'position' => 0]); + $participant = casParticipant($org, $project, 'in_corso'); + + $response = olnGet($participant)->assertOk(); + + $response->assertJsonPath('assessment_type', 'potential')->assertJsonPath('role_name', null); + expect($response->json('competencies.0.name'))->toBe('Mastery'); +}); + +test('an unset persona name, an invalid one and a blank participant name all return null', function (string $stored): void { + [, $project, , $participant] = olnWorld(1); + $project->update(['avatar_template_id' => olnTemplate($stored === 'unset' ? null : ($stored === 'long' ? str_repeat('a', 61) : 'Giulia'))->id]); + DB::table('participants')->where('id', $participant->id)->update(['display_name' => ' ']); + + olnGet($participant)->assertOk() + ->assertJsonPath('avatar_name', null) + ->assertJsonPath('candidate_name', null); +})->with(['unset', 'long', 'angle bracket']); + +test('a 60 character persona name is returned unchanged', function (): void { + [, $project, , $participant] = olnWorld(1); + $name = str_repeat('a', 60); + $project->update(['avatar_template_id' => olnTemplate($name)->id]); + + olnGet($participant)->assertOk()->assertJsonPath('avatar_name', $name); +}); + +// ─── Status ─────────────────────────────────────────────────────────────────── + +test('status follows the interview state', function (): void { + [, $project, $competencies, $participant] = olnWorld(3); + olnSession($participant, $project, $competencies[0], 'completed'); + olnSession($participant, $project, $competencies[1], 'in_corso'); + + expect(olnStatuses(olnGet($participant)->assertOk()))->toBe(['done', 'current', 'upcoming']); +}); + +test('an errored competency with attempts left that is next reports current', function (): void { + [, $project, $competencies, $participant] = olnWorld(3); + olnSession($participant, $project, $competencies[0], 'completed'); + olnSession($participant, $project, $competencies[1], 'error', errorCount: 1); + + expect(olnStatuses(olnGet($participant)->assertOk()))->toBe(['done', 'current', 'upcoming']); +}); + +test('a later competency queued behind the current one reports retry', function (): void { + [, $project, $competencies, $participant] = olnWorld(4); + olnSession($participant, $project, $competencies[0], 'completed'); + olnSession($participant, $project, $competencies[1], 'pending'); + olnSession($participant, $project, $competencies[2], 'completed'); + olnSession($participant, $project, $competencies[3], 'pending'); + + expect(olnStatuses(olnGet($participant)->assertOk()))->toBe(['done', 'current', 'done', 'retry']); +}); + +test('exhausted re-offer attempts close the competency', function (): void { + [, $project, $competencies, $participant] = olnWorld(3); + olnSession($participant, $project, $competencies[0], 'completed'); + olnSession($participant, $project, $competencies[1], 'error', errorCount: InterviewSession::MAX_ERROR_ATTEMPTS); + + expect(olnStatuses(olnGet($participant)->assertOk()))->toBe(['done', 'done', 'current']); +}); + +test('timeout and skipped sessions are done', function (): void { + [, $project, $competencies, $participant] = olnWorld(3); + olnSession($participant, $project, $competencies[0], 'timeout'); + olnSession($participant, $project, $competencies[1], 'skipped'); + + expect(olnStatuses(olnGet($participant)->assertOk()))->toBe(['done', 'done', 'current']); +}); + +test('a participant still in progress with every competency closed has no current competency', function (): void { + [, $project, $competencies, $participant] = olnWorld(2); + olnSession($participant, $project, $competencies[0], 'completed'); + olnSession($participant, $project, $competencies[1], 'completed'); + + expect(olnStatuses(olnGet($participant)->assertOk()))->toBe(['done', 'done']); +}); + +// ─── Refusals ───────────────────────────────────────────────────────────────── + +test('a terminal participant is refused by the participant status guard', function (string $status): void { + [$org, $project] = olnWorld(1); + $terminal = casParticipant($org, $project, $status); + + olnGet($terminal)->assertForbidden()->assertJsonMissingPath('competencies'); +})->with(['completato', 'errore']); + +test('an unsupported assessment type is refused like /start', function (): void { + [, $project, , $participant] = olnWorld(1); + DB::table('projects')->where('id', $project->id)->update(['assessment_type' => 'exotic']); + + olnGet($participant)->assertStatus(422)->assertJsonPath('error', 'assessment_type_not_supported'); +}); + +test('a participant whose project was deleted is refused like /start', function (): void { + [, $project, , $participant] = olnWorld(1); + $project->delete(); + + olnGet($participant)->assertStatus(422)->assertJsonPath('error', 'project_not_found'); +}); + +test('no token and an expired token are refused with the status /candidate/session gives', function (): void { + [, , , $participant] = olnWorld(1); + + $whoamiAnonymous = $this->getJson('/api/candidate/session')->getStatusCode(); + $this->getJson(OLN_URL)->assertStatus($whoamiAnonymous); + + $token = casBearer($participant); + Carbon::setTestNow(Carbon::now()->addHours(3)); + $whoamiExpired = $this->withHeaders(['Authorization' => 'Bearer '.$token])->getJson('/api/candidate/session')->getStatusCode(); + $this->withHeaders(['Authorization' => 'Bearer '.$token])->getJson(OLN_URL)->assertStatus($whoamiExpired); + + expect([$whoamiAnonymous, $whoamiExpired])->toBe([401, 401]); +}); + +// ─── primary_total ──────────────────────────────────────────────────────────── + +test('a frozen session snapshot beats the live primary count', function (): void { + [, $project, $competencies, $participant] = olnWorld(2); + olnAddPrimaries($project, $competencies[0], 2); // live count for competency 1 is now 3 + olnSession($participant, $project, $competencies[0], 'in_corso', primaries: ['Frozen one', 'Frozen two']); + + $response = olnGet($participant)->assertOk(); + + expect(array_column($response->json('competencies'), 'primary_total'))->toBe([2, 1]); + $response->assertJsonPath('primary_total_all', 3); +}); + +test('a competency without a primary question reports zero', function (): void { + [, $project, $competencies, $participant] = olnWorld(3); + ProjectQuestion::where('project_id', $project->id)->where('competency_id', $competencies[1]->id)->delete(); + + $response = olnGet($participant)->assertOk(); + + expect(array_column($response->json('competencies'), 'primary_total'))->toBe([1, 0, 1]); + $response->assertJsonPath('primary_total_all', 2); +}); + +// ─── Names ──────────────────────────────────────────────────────────────────── + +test('names follow the project language, not the request language', function (): void { + [, $project, , $participant] = olnWorld(2, 'en'); + Role::where('code', $project->role_code)->firstOrFail() + ->setTranslations('name', ['en' => 'Sales Lead', 'it' => 'Responsabile Vendite'])->save(); + + $response = olnGet($participant, ['Accept-Language' => 'it'])->assertOk(); + + $response->assertJsonPath('role_name', 'Sales Lead'); + expect(array_column($response->json('competencies'), 'name'))->toBe(['Skill 1', 'Skill 2']); +}); + +// A name blank in EVERY language cannot be stored (`framework_*_name_nonblank_check` requires a non-blank `en`), +// so the catalogue default is the last fallback a real row can reach. +test('a blank project-language name falls back to the catalogue default and never to the code', function (): void { + [, , $competencies, $participant] = olnWorld(3, 'it'); + $competencies[0]->setTranslations('name', ['en' => 'English only', 'it' => ''])->save(); + + $response = olnGet($participant)->assertOk(); + + expect(array_column($response->json('competencies'), 'name'))->toBe(['English only', 'Competenza 2', 'Competenza 3']); + foreach ($competencies as $competency) { + expect($response->getContent())->not->toContain($competency->code); + } +}); + +// ─── Tenant scope, identifiers and mutations ───────────────────────────────── + +test("another tenant's data is never returned", function (): void { + [, $projectA, $competenciesA, $participantA] = olnWorld(2); + olnAddPrimaries($projectA, $competenciesA[0], 1); // org A: 2 primaries on competency 1 + $projectA->update(['avatar_template_id' => olnTemplate('Alpha')->id]); + + $orgB = casOrg(); + [$projectB] = casProject($orgB, 1); + DB::table('project_competencies')->where('project_id', $projectB->id)->delete(); + foreach ($competenciesA as $position => $competency) { + DB::table('project_competencies')->insert(['project_id' => $projectB->id, 'competency_id' => $competency->id, 'position' => $position]); + } + olnAddPrimaries($projectB, $competenciesA[0], 4); // org B: 4 primaries on the SAME competency + $projectB->update(['avatar_template_id' => olnTemplate('Beta')->id]); + $participantB = casParticipant($orgB, $projectB, 'in_corso'); + olnSession($participantB, $projectB, $competenciesA[0], 'completed'); + + $response = olnGet($participantA)->assertOk(); + + $response->assertJsonPath('avatar_name', 'Alpha'); + expect($response->json('competencies.0.primary_total'))->toBe(2) + ->and(olnStatuses($response))->toBe(['current', 'upcoming']); + expect(olnGet($participantB)->assertOk()->json('avatar_name'))->toBe('Beta'); +}); + +test('the endpoint accepts no identifiers and mutates nothing', function (): void { + [$org, $project, $competencies, $participant] = olnWorld(2); + olnSession($participant, $project, $competencies[0], 'in_corso'); + $other = casParticipant($org, $project, 'in_corso'); + $foreign = olnSession($other, $project, $competencies[1], 'completed'); + + $baseline = olnGet($participant)->assertOk()->json(); + $tables = fn () => [ + DB::table('interview_sessions')->count(), + DB::table('utterances')->count(), + Participant::withoutGlobalScopes()->whereKey($participant->id)->value('status'), + ]; + $before = $tables(); + + $withForeign = olnGet($participant, query: "?participant_id={$other->id}&session_id={$foreign->id}&project_id=999")->assertOk()->json(); + + expect($withForeign)->toBe($baseline) + ->and($tables())->toBe($before); +}); + +test('the outline is read-only: a repeated call changes no row', function (): void { + [, $project, $competencies, $participant] = olnWorld(2); + olnSession($participant, $project, $competencies[0], 'in_corso'); + $snapshot = fn () => DB::table('interview_sessions')->orderBy('id')->get()->toJson(); + $before = $snapshot(); + + olnGet($participant)->assertOk(); + olnGet($participant)->assertOk(); + + expect($snapshot())->toBe($before); +}); diff --git a/tests/Feature/Contract/InterviewOutlineDocumentedTest.php b/tests/Feature/Contract/InterviewOutlineDocumentedTest.php new file mode 100644 index 00000000..bab9631a --- /dev/null +++ b/tests/Feature/Contract/InterviewOutlineDocumentedTest.php @@ -0,0 +1,59 @@ + + */ +function outlineDocumentedSchema(): array +{ + $spec = json_decode((string) file_get_contents(base_path('openapi.json')), true, flags: JSON_THROW_ON_ERROR); + $operation = $spec['paths']['/candidate/interview/outline']['get'] ?? null; + + expect($operation)->not->toBeNull('openapi.json documents no GET /candidate/interview/outline') + ->and($operation['operationId'])->toBe('outline.show'); + + return $operation['responses']['200']['content']['application/json']['schema']; +} + +test('the outline documents its six fields, its nullable names and the status enumeration', function (): void { + $schema = outlineDocumentedSchema(); + + expect(array_keys($schema['properties']))->toBe( + ['assessment_type', 'role_name', 'avatar_name', 'candidate_name', 'primary_total_all', 'competencies'], + ) + ->and($schema['required'])->toBe(array_keys($schema['properties'])) + ->and($schema['properties']['assessment_type']['enum'])->toBe(['standard', 'potential']) + ->and($schema['properties']['role_name']['type'])->toBe(['string', 'null']) + ->and($schema['properties']['avatar_name']['type'])->toBe(['string', 'null']) + ->and($schema['properties']['candidate_name']['type'])->toBe(['string', 'null']); +}); + +test('a competency entry carries a status enumeration and no code or identifier', function (): void { + $entry = outlineDocumentedSchema()['properties']['competencies']['items']; + + expect(array_keys($entry['properties']))->toBe(['name', 'ordinal', 'status', 'primary_total']) + ->and($entry['properties']['status']['enum'])->toBe(['done', 'current', 'upcoming', 'retry']) + ->and($entry['properties']['name']['type'])->toBe(['string', 'null']); +}); + +test('the outline documents its refusals: 401, 403 from the status guard and both 422 codes', function (): void { + $spec = json_decode((string) file_get_contents(base_path('openapi.json')), true, flags: JSON_THROW_ON_ERROR); + $responses = $spec['paths']['/candidate/interview/outline']['get']['responses']; + + expect(array_keys($responses))->toEqualCanonicalizing(['200', '401', '403', '422']); + + $codes = $responses['422']['content']['application/json']['schema']['properties']['error']['enum'] ?? []; + expect($codes)->toEqualCanonicalizing(['assessment_type_not_supported', 'project_not_found']); +}); diff --git a/tests/Feature/Contract/PersonaDisplayNameDocumentedTest.php b/tests/Feature/Contract/PersonaDisplayNameDocumentedTest.php new file mode 100644 index 00000000..f2446b87 --- /dev/null +++ b/tests/Feature/Contract/PersonaDisplayNameDocumentedTest.php @@ -0,0 +1,51 @@ + + */ +function personaDocumentedSpec(): array +{ + return json_decode((string) file_get_contents(base_path('openapi.json')), true, flags: JSON_THROW_ON_ERROR); +} + +test('every avatar template write documents an optional nullable persona.display_name', function (string $path, string $method): void { + $operation = personaDocumentedSpec()['paths'][$path][$method] ?? null; + expect($operation)->not->toBeNull("openapi.json documents no {$method} {$path}"); + + $schema = $operation['requestBody']['content']['application/json']['schema']; + $persona = $schema['properties']['persona']; + + expect($persona['properties']['display_name']['type'])->toBe(['string', 'null']) + ->and($persona['required'] ?? [])->not->toContain('display_name') + ->and($schema['required'] ?? [])->not->toContain('persona'); +})->with([ + 'organization create' => ['/avatar-templates', 'post'], + 'organization update' => ['/avatar-templates/{id}', 'patch'], + 'platform create' => ['/admin/avatar-templates', 'post'], + 'platform update' => ['/admin/avatar-templates/{id}', 'patch'], +]); + +test('both admin avatar template resources document a required persona with a nullable display_name only', function (string $schemaName): void { + $schema = personaDocumentedSpec()['components']['schemas'][$schemaName] ?? null; + expect($schema)->not->toBeNull("openapi.json declares no schema named {$schemaName}"); + + $persona = $schema['properties']['persona']; + + expect(array_keys($persona['properties']))->toBe(['display_name']) + ->and($persona['properties']['display_name']['type'])->toBe(['string', 'null']) + ->and($persona['required'])->toBe(['display_name']) + ->and($schema['required'])->toContain('persona'); +})->with(['AvatarTemplateResource', 'PlatformAvatarTemplateResource']); diff --git a/tests/Feature/Contract/UtterancePrimaryIndexDocumentedTest.php b/tests/Feature/Contract/UtterancePrimaryIndexDocumentedTest.php new file mode 100644 index 00000000..c990e17d --- /dev/null +++ b/tests/Feature/Contract/UtterancePrimaryIndexDocumentedTest.php @@ -0,0 +1,19 @@ +toBe('integer') + ->and($schema['properties']['boundary_due']['type'])->toBe('boolean') + ->and($schema['required'])->toBe(['boundary_due', 'primary_index']); +}); diff --git a/tests/Feature/Interview/BoundaryDueTest.php b/tests/Feature/Interview/BoundaryDueTest.php index 25487d19..f757caab 100644 --- a/tests/Feature/Interview/BoundaryDueTest.php +++ b/tests/Feature/Interview/BoundaryDueTest.php @@ -84,36 +84,36 @@ function boundaryDuePost(mixed $test, InterviewSession $session, string $token, [$session, $token] = boundaryDueSession(); boundaryDueSeed($session, 4); - boundaryDuePost($this, $session, $token)->assertStatus(202)->assertExactJson(['boundary_due' => false]); + boundaryDuePost($this, $session, $token)->assertStatus(202)->assertExactJson(['boundary_due' => false, 'primary_index' => 0]); }); test('at and above 1 + budget + grace substantive turns boundary_due is true', function (): void { [$session, $token] = boundaryDueSession(); boundaryDueSeed($session, 5); - boundaryDuePost($this, $session, $token)->assertStatus(202)->assertExactJson(['boundary_due' => true]); - boundaryDuePost($this, $session, $token)->assertStatus(202)->assertExactJson(['boundary_due' => true]); + boundaryDuePost($this, $session, $token)->assertStatus(202)->assertExactJson(['boundary_due' => true, 'primary_index' => 0]); + boundaryDuePost($this, $session, $token)->assertStatus(202)->assertExactJson(['boundary_due' => true, 'primary_index' => 0]); }); test('turns shorter than nudge_min_chars do not count', function (): void { [$session, $token] = boundaryDueSession(); boundaryDueSeed($session, 5, 'candidate', 'ok'); - boundaryDuePost($this, $session, $token, 'candidate', 'yes')->assertStatus(202)->assertExactJson(['boundary_due' => false]); + boundaryDuePost($this, $session, $token, 'candidate', 'yes')->assertStatus(202)->assertExactJson(['boundary_due' => false, 'primary_index' => 0]); }); test('every candidate turn counts when nudge_min_chars is null', function (): void { [$session, $token] = boundaryDueSession(nudge: null); boundaryDueSeed($session, 5, 'candidate', 'ok'); - boundaryDuePost($this, $session, $token, 'candidate', 'yes')->assertStatus(202)->assertExactJson(['boundary_due' => true]); + boundaryDuePost($this, $session, $token, 'candidate', 'yes')->assertStatus(202)->assertExactJson(['boundary_due' => true, 'primary_index' => 0]); }); test('avatar turns never count', function (): void { [$session, $token] = boundaryDueSession(); boundaryDueSeed($session, 5, 'avatar'); - boundaryDuePost($this, $session, $token)->assertStatus(202)->assertExactJson(['boundary_due' => false]); + boundaryDuePost($this, $session, $token)->assertStatus(202)->assertExactJson(['boundary_due' => false, 'primary_index' => 0]); }); test('the threshold uses the row follow_up_budget snapshot, not the global config', function (): void { @@ -122,7 +122,7 @@ function boundaryDuePost(mixed $test, InterviewSession $session, string $token, boundaryDueSeed($session, 2); // 1 + 1 + 1 = 3: the third substantive turn is due although the global budget would need 6. - boundaryDuePost($this, $session, $token)->assertStatus(202)->assertExactJson(['boundary_due' => true]); + boundaryDuePost($this, $session, $token)->assertStatus(202)->assertExactJson(['boundary_due' => true, 'primary_index' => 0]); }); test('a row without a snapshot falls back to the configured budget', function (): void { @@ -131,9 +131,9 @@ function boundaryDuePost(mixed $test, InterviewSession $session, string $token, boundaryDueSeed($session, 4); // Threshold: 1 + 3 (config) + 2 (config) = 6. At 5 turns (4 seeded + 1 posted), should be false. - boundaryDuePost($this, $session, $token)->assertStatus(202)->assertExactJson(['boundary_due' => false]); + boundaryDuePost($this, $session, $token)->assertStatus(202)->assertExactJson(['boundary_due' => false, 'primary_index' => 0]); // At 6 turns, should be true. - boundaryDuePost($this, $session, $token)->assertStatus(202)->assertExactJson(['boundary_due' => true]); + boundaryDuePost($this, $session, $token)->assertStatus(202)->assertExactJson(['boundary_due' => true, 'primary_index' => 0]); }); test('409 and 422 are unchanged', function (): void { diff --git a/tests/Helpers/AuthMatrix/AuthMatrixCandidateFixtures.php b/tests/Helpers/AuthMatrix/AuthMatrixCandidateFixtures.php index cd70e6a3..56dcd4f6 100644 --- a/tests/Helpers/AuthMatrix/AuthMatrixCandidateFixtures.php +++ b/tests/Helpers/AuthMatrix/AuthMatrixCandidateFixtures.php @@ -44,6 +44,7 @@ public static function keys(): array { return [ 'POST api/candidate/interview/end', + 'GET api/candidate/interview/outline', 'POST api/candidate/interview/integrity', 'POST api/candidate/interview/snapshot', 'POST api/candidate/interview/start', diff --git a/tests/Helpers/AuthMatrix/AuthMatrixCatalogue.php b/tests/Helpers/AuthMatrix/AuthMatrixCatalogue.php index e517dbd5..5eefd069 100644 --- a/tests/Helpers/AuthMatrix/AuthMatrixCatalogue.php +++ b/tests/Helpers/AuthMatrix/AuthMatrixCatalogue.php @@ -300,6 +300,7 @@ public static function entries(): array // ─── candidate (candidate JWT) ─────────────────────────────────── 'POST api/candidate/interview/end' => self::candidate(true), + 'GET api/candidate/interview/outline' => self::candidate(true), 'POST api/candidate/interview/integrity' => self::candidate(true), 'POST api/candidate/interview/snapshot' => self::candidate(true), 'POST api/candidate/interview/start' => self::candidate(true), diff --git a/tests/Helpers/AuthMatrix/AuthMatrixMachineDatasets.php b/tests/Helpers/AuthMatrix/AuthMatrixMachineDatasets.php index c684e386..eaa7cc83 100644 --- a/tests/Helpers/AuthMatrix/AuthMatrixMachineDatasets.php +++ b/tests/Helpers/AuthMatrix/AuthMatrixMachineDatasets.php @@ -135,6 +135,7 @@ private static function candidateLifecycleStatus(string $key, string $status): i } return match ($key) { + 'GET api/candidate/interview/outline' => 200, 'POST api/candidate/interview/start' => $status === 'in_valutazione' ? 422 : 201, 'POST api/candidate/interview/suspend' => $status === 'in_corso' ? 200 : 409, 'POST api/candidate/interview/end' => $status === 'in_corso' ? 200 : 409, diff --git a/tests/Helpers/PublicApi/ExposureCatalogue.php b/tests/Helpers/PublicApi/ExposureCatalogue.php index 21e7bba7..2e417d41 100644 --- a/tests/Helpers/PublicApi/ExposureCatalogue.php +++ b/tests/Helpers/PublicApi/ExposureCatalogue.php @@ -81,6 +81,7 @@ public static function exclusions(): array 'avatar_template.pal_sync.status', 'avatar_template.pal_sync.code', 'avatar_template.pal_sync.synced_at', + 'avatar_template.persona.display_name', 'can.update', 'can.delete', 'competencies[].id', diff --git a/tests/Unit/Rules/PersonaDisplayNameTest.php b/tests/Unit/Rules/PersonaDisplayNameTest.php new file mode 100644 index 00000000..81edfc8c --- /dev/null +++ b/tests/Unit/Rules/PersonaDisplayNameTest.php @@ -0,0 +1,111 @@ + $value], ['name' => [new PersonaDisplayName]])->fails(); +} + +test('1 to 60 characters after trimming are accepted and 61 are refused', function (): void { + expect(PersonaDisplayName::problem(str_repeat('a', 60)))->toBeNull() + ->and(PersonaDisplayName::problem(' '.str_repeat('a', 60).' '))->toBeNull() + ->and(PersonaDisplayName::problem('A'))->toBeNull() + ->and(PersonaDisplayName::problem(str_repeat('a', 61)))->not->toBeNull() + ->and(PersonaDisplayName::problem(str_repeat('è', 61)))->not->toBeNull() + ->and(PersonaDisplayName::problem(str_repeat('è', 60)))->toBeNull() + ->and(PersonaDisplayName::problem(' '))->not->toBeNull(); +}); + +test('markup, line breaks and format characters are refused', function (string $value): void { + expect(PersonaDisplayName::problem($value))->not->toBeNull() + ->and(personaNameFails($value))->toBeTrue(); +})->with([ + 'opening bracket' => 'Giulia', + 'closing bracket only' => 'Giulia>', + 'line feed' => "Giu\nlia", + 'tab' => "Giu\tlia", + 'null byte' => "Giu\0lia", + 'zero width space' => "Giu\u{200B}lia", + 'malformed UTF-8 hiding markup' => "\xFF", + 'trailing null byte' => "Giulia\0", + 'trailing line feed' => "Giulia\n", + 'leading vertical tab' => "\x0BGiulia", + 'unicode line separator' => "Giu\u{2028}lia", +]); + +test('a provider or infrastructure name is refused in any case and spacing', function (string $value): void { + expect(PersonaDisplayName::problem($value))->not->toBeNull(); +})->with([ + 'HeyGen' => 'HeyGen', + 'spaced HeyGen' => 'Hey Gen', + 'lower case' => 'heygen', + 'LiveAvatar' => 'LiveAvatar', + 'spaced LiveAvatar' => 'live avatar', + 'Tavus in a phrase' => 'Tavus Coach', + 'daily' => 'daily', + 'daily.co' => 'daily.co', + 'LiveKit in a phrase' => 'My LiveKit agent', + 'spaced LiveKit' => 'live kit', + 'upper case' => 'TAVUS', + 'malformed UTF-8 after the name' => "Tavus\xC3", + 'underscore suffix' => 'Tavus_bot', + 'hyphen inside HeyGen' => 'Hey-Gen', + 'underscore inside LiveKit' => 'Live_Kit', + 'dot inside LiveAvatar' => 'live.avatar', + 'mixed separators' => 'Live - Avatar', +]); + +test('a name of only Unicode spaces is refused', function (string $value): void { + expect(PersonaDisplayName::problem($value))->not->toBeNull(); +})->with([ + 'no-break space' => "\u{00A0}", + 'ideographic space' => "\u{3000}\u{3000}", + 'mixed spaces' => " \u{00A0}\u{2003}", +]); + +test('a name that merely resembles a vendor word is accepted', function (string $value): void { + expect(PersonaDisplayName::problem($value))->toBeNull(); +})->with(['Giulia', 'Dailyn', 'Taverna', 'Heygenie', 'Live Aviator', 'LiveKitBot', 'Marco Rossi']); + +test('a value that is not text is refused', function (mixed $value): void { + expect(PersonaDisplayName::problem($value))->not->toBeNull() + ->and(personaNameFails($value))->toBeTrue(); +})->with([[123], [['Giulia']], [true]]); + +test('the refusal message never repeats the matched vendor word', function (string $value, string $word): void { + $message = (string) PersonaDisplayName::problem($value); + + expect($message)->not->toBe('') + ->and(mb_strtolower($message))->not->toContain($word); + expect(Validator::make(['name' => $value], ['name' => [new PersonaDisplayName]])->errors()->first('name')) + ->not->toContain($word); +})->with([ + ['Tavus Coach', 'tavus'], + ['heygen', 'heygen'], + ['My LiveKit agent', 'livekit'], + ['live avatar', 'avatar'], +]); + +test('the rule passes a valid name through the validator', function (): void { + expect(personaNameFails('Giulia'))->toBeFalse(); +}); diff --git a/tests/Unit/Support/Interview/CompetencyProgressTest.php b/tests/Unit/Support/Interview/CompetencyProgressTest.php new file mode 100644 index 00000000..f9df97f7 --- /dev/null +++ b/tests/Unit/Support/Interview/CompetencyProgressTest.php @@ -0,0 +1,185 @@ +create(); + + $resolver = app(TenantResolver::class); + $resolver->setOrgId($org->id); + $resolver->setBypass(false); + + return $org; +} + +/** + * @param list|null $competencies reused so two projects can share codes + * @return array{Project, list} + */ +function cprProject(int $count, ?array $competencies = null): array +{ + $project = Project::factory()->create(['status' => 'active']); + $competencies ??= array_map(fn () => Competency::factory()->create(), range(1, $count)); + + foreach ($competencies as $position => $competency) { + DB::table('project_competencies')->insert([ + 'project_id' => $project->id, + 'competency_id' => $competency->id, + 'position' => $position, + ]); + } + + return [$project, $competencies]; +} + +/** + * @param array $overrides + */ +function cprSession(Participant $participant, Project $project, string $code, array $overrides = []): InterviewSession +{ + $session = InterviewSession::create([ + 'participant_id' => $participant->id, + 'project_id' => $project->id, + 'question_index' => 0, + 'competency_code' => $code, + 'framework_version_id' => $project->framework_version_id, + 'provider' => 'fake', + 'provider_session_ref' => 'ref-'.uniqid(), + 'status' => $overrides['status'] ?? 'pending', + ]); + + if (array_key_exists('error_count', $overrides)) { + DB::table('interview_sessions')->where('id', $session->id)->update(['error_count' => $overrides['error_count']]); + $session->refresh(); + } + + return $session; +} + +test('isOpen is true for a missing session and for pending and in_corso sessions', function (): void { + cprOrg(); + [$project, $comps] = cprProject(2); + $participant = Participant::factory()->forProject($project)->withStatus('in_corso')->create(); + + expect(CompetencyProgress::isOpen(null))->toBeTrue() + ->and(CompetencyProgress::isOpen(cprSession($participant, $project, $comps[0]->code, ['status' => 'pending'])))->toBeTrue() + ->and(CompetencyProgress::isOpen(cprSession($participant, $project, $comps[1]->code, ['status' => 'in_corso'])))->toBeTrue(); +}); + +test('isOpen treats an errored session as open only while attempts are left', function (): void { + cprOrg(); + [$project, $comps] = cprProject(2); + $participant = Participant::factory()->forProject($project)->withStatus('in_corso')->create(); + + $retriable = cprSession($participant, $project, $comps[0]->code, ['status' => 'error', 'error_count' => InterviewSession::MAX_ERROR_ATTEMPTS - 1]); + $exhausted = cprSession($participant, $project, $comps[1]->code, ['status' => 'error', 'error_count' => InterviewSession::MAX_ERROR_ATTEMPTS]); + + expect(CompetencyProgress::isOpen($retriable))->toBeTrue() + ->and(CompetencyProgress::isOpen($exhausted))->toBeFalse(); +}); + +test('isOpen is false for completed, timeout and skipped sessions', function (): void { + cprOrg(); + [$project, $comps] = cprProject(3); + $participant = Participant::factory()->forProject($project)->withStatus('in_corso')->create(); + + foreach (['completed', 'timeout', 'skipped'] as $i => $status) { + expect(CompetencyProgress::isOpen(cprSession($participant, $project, $comps[$i]->code, ['status' => $status]))) + ->toBeFalse($status.' closes a competency'); + } +}); + +test('ordered returns the project rows by position and only that project', function (): void { + cprOrg(); + [$projectA, $shared] = cprProject(3); + [$projectB] = cprProject(3, array_reverse($shared)); + + $a = app(CompetencyProgress::class)->ordered($projectA->id); + $b = app(CompetencyProgress::class)->ordered($projectB->id); + + expect($a->pluck('competency_code')->all())->toBe(array_map(fn (Competency $c) => $c->code, $shared)) + ->and($a->pluck('position')->all())->toBe([0, 1, 2]) + ->and($b->pluck('competency_code')->all())->toBe(array_map(fn (Competency $c) => $c->code, array_reverse($shared))) + ->and($a->first()->competency_id)->toBe($shared[0]->id); +}); + +test('resolveNext offers the first competency with its ordinal, total and position as question_index', function (): void { + cprOrg(); + [$project, $comps] = cprProject(3); + $participant = Participant::factory()->forProject($project)->withStatus('in_attesa')->create(); + + expect(app(CompetencyProgress::class)->resolveNext($participant->id, $project->id))->toBe([ + 'competency_code' => $comps[0]->code, + 'question_index' => 0, + 'competency_ordinal' => 1, + 'total_competencies' => 3, + ]); +}); + +test('resolveNext skips closed competencies and resumes a pending one', function (): void { + cprOrg(); + [$project, $comps] = cprProject(3); + $participant = Participant::factory()->forProject($project)->withStatus('in_corso')->create(); + + cprSession($participant, $project, $comps[0]->code, ['status' => 'completed']); + cprSession($participant, $project, $comps[1]->code, ['status' => 'pending']); + + expect(app(CompetencyProgress::class)->resolveNext($participant->id, $project->id))->toBe([ + 'competency_code' => $comps[1]->code, + 'question_index' => 1, + 'competency_ordinal' => 2, + 'total_competencies' => 3, + ]); +}); + +test('resolveNext flags a re-offer for an errored session with attempts left and skips an exhausted one', function (): void { + cprOrg(); + [$project, $comps] = cprProject(3); + $participant = Participant::factory()->forProject($project)->withStatus('in_corso')->create(); + + cprSession($participant, $project, $comps[0]->code, ['status' => 'error', 'error_count' => InterviewSession::MAX_ERROR_ATTEMPTS]); + cprSession($participant, $project, $comps[1]->code, ['status' => 'error', 'error_count' => 1]); + + $next = app(CompetencyProgress::class)->resolveNext($participant->id, $project->id); + + expect($next)->toBe([ + 'competency_code' => $comps[1]->code, + 'question_index' => 1, + 'competency_ordinal' => 2, + 'total_competencies' => 3, + 'reoffer' => true, + ]); +}); + +test('resolveNext is null once every competency is closed', function (): void { + cprOrg(); + [$project, $comps] = cprProject(2); + $participant = Participant::factory()->forProject($project)->withStatus('in_corso')->create(); + + cprSession($participant, $project, $comps[0]->code, ['status' => 'completed']); + cprSession($participant, $project, $comps[1]->code, ['status' => 'timeout']); + + expect(app(CompetencyProgress::class)->resolveNext($participant->id, $project->id))->toBeNull(); +});