api()->loginMobileAndGetToken( DatabaseSeeder::COACH_USERNAME, DatabaseSeeder::PASSWORD ); $f = $this->fixture(); $this->assertApiOk($this->submitFixtureQuestionnaire()); $this->assertSame(1, $this->submissionVersionCount($f->clientCode, $f->questionnaireId)); $res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [ ['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'], ]); $this->assertApiOk($res); $this->assertTrue($res['data']['submitted'] ?? false); $this->assertSame(2, $this->submissionVersionCount($f->clientCode, $f->questionnaireId)); [$pdo, $tmpDb, $lockFp] = qdb_open(false); $stmt = $pdo->prepare( 'SELECT COUNT(*) FROM client_answer_submission cas INNER JOIN questionnaire_submission qs ON qs.submissionID = cas.submissionID WHERE qs.clientCode = :cc AND qs.questionnaireID = :qn' ); $stmt->execute([':cc' => $f->clientCode, ':qn' => $f->questionnaireId]); $this->assertGreaterThanOrEqual(2, (int)$stmt->fetchColumn()); qdb_discard($tmpDb, $lockFp); } public function testSupervisorCanSubmitForClient(): void { $login = $this->api()->loginMobileAndGetToken( DatabaseSeeder::SUPERVISOR_USERNAME, DatabaseSeeder::PASSWORD ); $f = $this->fixture(); $res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [ ['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'], ]); $this->assertApiOk($res); [$pdo, $tmpDb, $lockFp] = qdb_open(false); $stmt = $pdo->prepare( 'SELECT submittedByRole FROM questionnaire_submission WHERE clientCode = :cc AND questionnaireID = :qn ORDER BY version DESC LIMIT 1' ); $stmt->execute([':cc' => $f->clientCode, ':qn' => $f->questionnaireId]); $this->assertSame('supervisor', $stmt->fetchColumn()); qdb_discard($tmpDb, $lockFp); } public function testSubmitWithFreeTextAndGlassSymptom(): void { $login = $this->api()->loginMobileAndGetToken( DatabaseSeeder::COACH_USERNAME, DatabaseSeeder::PASSWORD ); $f = $this->fixture(); $res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [ ['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'], ['questionID' => $f->freeTextShortId, 'freeTextValue' => 'Feeling better'], ['questionID' => $f->glassSymptomKey, 'freeTextValue' => 'moderate'], ]); $this->assertApiOk($res); [$pdo, $tmpDb, $lockFp] = qdb_open(false); $ft = $pdo->prepare( 'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid' ); $ft->execute([':cc' => $f->clientCode, ':qid' => $f->freeTextQuestionId]); $this->assertSame('Feeling better', $ft->fetchColumn()); $glass = $pdo->prepare( 'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid' ); $glass->execute([':cc' => $f->clientCode, ':qid' => $f->glassQuestionId]); $json = (string)$glass->fetchColumn(); $decoded = json_decode($json, true); $this->assertIsArray($decoded); $this->assertSame('moderate', $decoded[$f->glassSymptomKey] ?? null); qdb_discard($tmpDb, $lockFp); } public function testCoachLoadsEncryptedAnswersBundle(): void { $this->submitFixtureQuestionnaire(); $login = $this->api()->loginMobileAndGetToken( DatabaseSeeder::COACH_USERNAME, DatabaseSeeder::PASSWORD ); $f = $this->fixture(); $res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [ 'clientCode' => $f->clientCode, 'answers' => '1', ]); $this->assertApiOk($res); $this->assertTrue($res['data']['encrypted'] ?? false); $plain = qdb_decrypt_sensitive_envelope($res['data'], $login['token']); $payload = json_decode($plain, true, 512, JSON_THROW_ON_ERROR); $this->assertSame($f->clientCode, $payload['clientCode']); $this->assertNotEmpty($payload['questionnaires']); } public function testMultiCheckSubmitAccumulatesPoints(): void { $login = $this->api()->loginMobileAndGetToken( DatabaseSeeder::COACH_USERNAME, DatabaseSeeder::PASSWORD ); $f = $this->fixture(); $res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [ ['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'], ['questionID' => $f->multiCheckShortId, 'answerOptionKey' => 'opt_a'], ['questionID' => $f->multiCheckShortId, 'answerOptionKey' => 'opt_b'], ]); $this->assertApiOk($res); $this->assertSame(3, $res['data']['sumPoints'] ?? 0); } public function testRequiredQuestionMissingFailsValidation(): void { $login = $this->api()->loginMobileAndGetToken( DatabaseSeeder::COACH_USERNAME, DatabaseSeeder::PASSWORD ); $f = $this->fixture(); $res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [ ['questionID' => $f->freeTextShortId, 'freeTextValue' => 'skipped consent'], ]); $this->assertApiError($res, 'VALIDATION_FAILED', 400); $codes = array_column($res['error']['details']['errors'] ?? [], 'code'); $this->assertContains('MISSING_ANSWER', $codes); } public function testActiveListExcludesDraftQuestionnaire(): void { $login = $this->api()->loginMobileAndGetToken( DatabaseSeeder::COACH_USERNAME, DatabaseSeeder::PASSWORD ); $f = $this->fixture(); $res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires'); $this->assertApiOk($res); $ids = array_column($res['data'], 'id'); $this->assertContains($f->questionnaireId, $ids); $this->assertNotContains($f->draftQuestionnaireId, $ids); } public function testTempTokenCannotSubmitQuestionnaire(): void { [$pdo, $tmpDb, $lockFp] = qdb_open(true); $f = $this->fixture(); $pdo->prepare('UPDATE users SET mustChangePassword = 1 WHERE userID = :uid') ->execute([':uid' => $f->supervisorUserId]); qdb_save($tmpDb, $lockFp); $login = $this->api()->loginMobile( DatabaseSeeder::SUPERVISOR_USERNAME, DatabaseSeeder::PASSWORD ); $this->assertApiOk($login); $tempToken = $login['data']['token']; $res = $this->api()->encryptedPost($tempToken, 'app_questionnaires', [ 'questionnaireID' => $f->questionnaireId, 'clientCode' => $f->clientCode, 'answers' => [ ['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'], ], ]); $this->assertApiError($res, 'PASSWORD_CHANGE_REQUIRED', 403); } }