Coverage for cc_modules/tests/client_api_tests.py: 99%

1"""

2 camcops_server/cc_modules/tests/client_api_tests.py

3

4===============================================================================

5

6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.

7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

8

9 This file is part of CamCOPS.

10

11 CamCOPS is free software: you can redistribute it and/or modify

12 it under the terms of the GNU General Public License as published by

13 the Free Software Foundation, either version 3 of the License, or

14 (at your option) any later version.

15

16 CamCOPS is distributed in the hope that it will be useful,

17 but WITHOUT ANY WARRANTY; without even the implied warranty of

18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the

19 GNU General Public License for more details.

20

21 You should have received a copy of the GNU General Public License

22 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.

23

24===============================================================================

25

26"""

27

28 import json

29 import string

30 from typing import Dict

31 from unittest import mock, TestCase

32

33 from cardinal_pythonlib.classes import class_attribute_names

34 from cardinal_pythonlib.convert import (

35 base64_64format_encode,

36 hex_xformat_encode,

37)

38 from cardinal_pythonlib.nhs import generate_random_nhs_number

39 from cardinal_pythonlib.sql.literals import sql_quote_string

40 from cardinal_pythonlib.text import escape_newlines, unescape_newlines

41 from pendulum import DateTime as Pendulum, Duration, local, now, parse

42 from pyramid.response import Response

43 from semantic_version import Version

44 from sqlalchemy import select

45

46 from camcops_server.cc_modules.cc_all_models import CLIENT_TABLE_MAP

47 from camcops_server.cc_modules.cc_cache import cache_region_static

48 from camcops_server.cc_modules.cc_client_api_core import (

49 AllowedTablesFieldNames,

50 ExtraStringFieldNames,

51 fail_server_error,

52 fail_unsupported_operation,

53 fail_user_error,

54 ServerErrorException,

55 TabletParam,

56 UserErrorException,

57)

58 from camcops_server.cc_modules.cc_constants import ERA_NOW

59 from camcops_server.cc_modules.cc_convert import decode_values

60 from camcops_server.cc_modules.cc_device import Device

61 from camcops_server.cc_modules.cc_dirtytables import DirtyTable

62 from camcops_server.cc_modules.cc_patientidnum import PatientIdNum

63 from camcops_server.cc_modules.cc_proquint import uuid_from_proquint

64 from camcops_server.cc_modules.cc_taskindex import (

65 PatientIdNumIndexEntry,

66 TaskIndexEntry,

67)

68 from camcops_server.cc_modules.cc_testfactories import (

69 DeviceFactory,

70 DirtyTableFactory,

71 Fake,

72 GroupFactory,

73 NHSIdNumDefinitionFactory,

74 NHSPatientIdNumFactory,

75 PatientFactory,

76 PatientTaskScheduleFactory,

77 ServerCreatedNHSPatientIdNumFactory,

78 ServerCreatedPatientFactory,

79 TaskScheduleFactory,

80 TaskScheduleItemFactory,

81 UserFactory,

82 UserGroupMembershipFactory,

83)

84 from camcops_server.cc_modules.cc_unittest import DemoRequestTestCase

85 from camcops_server.cc_modules.cc_user import User

86 from camcops_server.cc_modules.cc_version import (

87 FIRST_TABLET_VER_WITH_EXPLICIT_PKNAME_IN_UPLOAD_TABLE,

88)

89 from camcops_server.cc_modules.cc_validators import (

90 validate_alphanum_underscore,

91)

92 from camcops_server.cc_modules.client_api import (

93 client_api,

94 FAILURE_CODE,

95 get_or_create_single_user,

96 make_single_user_mode_username,

97 Operations,

98 SUCCESS_CODE,

99)

100 from camcops_server.tasks import Bmi

101 from camcops_server.tasks.tests.factories import BmiFactory, Phq9Factory

102

103 TEST_NHS_NUMBER = generate_random_nhs_number()

104

105

106 def get_reply_dict_from_response(response: Response) -> Dict[str, str]:

107 """

108 For unit testing: convert the text in a :class:`Response` back to a

109 dictionary, so we can check it was correct.

110 """

111 txt = str(response)

112 d = {} # type: Dict[str, str]

113 # Format is: "200 OK\r\n<other headers>\r\n\r\n<content>"

114 # There's a blank line between the heads and the body.

115 http_gap = "\r\n\r\n"

116 camcops_linesplit = "\n"

117 camcops_k_v_sep = ":"

118 try:

119 start_of_content = txt.index(http_gap) + len(http_gap)

120 txt = txt[start_of_content:]

121 for line in txt.split(camcops_linesplit):

122 if not line:

123 continue

124 colon_pos = line.index(camcops_k_v_sep)

125 key = line[:colon_pos]

126 value = line[colon_pos + len(camcops_k_v_sep) :]

127 key = key.strip()

128 value = value.strip()

129 d[key] = value

130 return d

131 except ValueError:

132 return {}

133

134

135 class ExceptionTests(TestCase):

136 def test_fail_user_error_raises(self) -> None:

137 with self.assertRaises(UserErrorException):

138 fail_user_error("testmsg")

139

140 def test_fail_server_error_raises(self) -> None:

141 with self.assertRaises(ServerErrorException):

142 fail_server_error("testmsg")

143

144 def test_fail_unsupported_operation_raises(self) -> None:

145 with self.assertRaises(UserErrorException):

146 fail_unsupported_operation("duffop")

147

148

149 class EncodeDecodeValuesTests(TestCase):

150 def test_values_decoded_correctly(self) -> None:

151 # Encoding/decoding tests

152 # data = bytearray("hello")

153 data = b"hello"

154 enc_b64data = base64_64format_encode(data)

155 enc_hexdata = hex_xformat_encode(data)

156 not_enc_1 = "X'012345'"

157 not_enc_2 = "64'aGVsbG8='"

158 teststring = """one, two, 3, 4.5, NULL, 'hello "hi

159 with linebreak"', 'NULL', 'quote''s here', {b}, {h}, {s1}, {s2}"""

160 sql_csv_testdict = {

161 teststring.format(

162 b=enc_b64data,

163 h=enc_hexdata,

164 s1=sql_quote_string(not_enc_1),

165 s2=sql_quote_string(not_enc_2),

166 ): [

167 "one",

168 "two",

169 3,

170 4.5,

171 None,

172 'hello "hi\n with linebreak"',

173 "NULL",

174 "quote's here",

175 data,

176 data,

177 not_enc_1,

178 not_enc_2,

179 ],

180 "": [],

181 }

182 for k, v in sql_csv_testdict.items():

183 r = decode_values(k)

184 self.assertEqual(

185 r,

186 v,

187 "Mismatch! Result: {r!s}\n"

188 "Should have been: {v!s}\n"

189 "Key was: {k!s}".format(r=r, v=v, k=k),

190 )

191

192

193 class EscapeUnescapeNewlinesTests(TestCase):

194 def test_escapes_and_unescapes_correctly(self) -> None:

195

196 # Newline encoding/decodine

197 test_string = (

198 "slash \\ newline \n ctrl_r \r special \\n other special \\r "

199 "quote ' doublequote \" "

200 )

201 self.assertEqual(

202 unescape_newlines(escape_newlines(test_string)),

203 test_string,

204 "Bug in escape_newlines() or unescape_newlines()",

205 )

206

207

208 class ValidateAlphanumUnderscoreTests(TestCase):

209 def test_class_attribute_names_validate(self) -> None:

210 for x in class_attribute_names(Operations):

211 try:

212 request = None

213 validate_alphanum_underscore(x, request)

214 except ValueError:

215 self.fail(f"Operations.{x} fails validate_alphanum_underscore")

216

217

218 class ClientApiTestCase(DemoRequestTestCase):

219 def setUp(self) -> None:

220 super().setUp()

221

222 self.group = GroupFactory()

223 self.user = self.req._debugging_user = UserFactory(

224 upload_group_id=self.group.id,

225 )

226

227 UserGroupMembershipFactory(

228 user_id=self.user.id,

229 group_id=self.group.id,

230 may_upload=True,

231 may_register_devices=True,

232 )

233 # Ensure the server device exists so that we don't get ID clashes

234 Device.get_server_device(self.dbsession)

235 self.device = DeviceFactory()

236

237 self.post_dict = {

238 TabletParam.CAMCOPS_VERSION: (

239 FIRST_TABLET_VER_WITH_EXPLICIT_PKNAME_IN_UPLOAD_TABLE

240 ),

241 TabletParam.DEVICE: self.device.name,

242 }

243 # To prevent test failures when mocking cached functions

244 cache_region_static.configure(

245 backend="dogpile.cache.null", replace_existing_backend=True

246 )

247

248 def call_api(self) -> Dict[str, str]:

249 self.req.fake_request_post_from_dict(self.post_dict)

250 response = client_api(self.req)

251 return get_reply_dict_from_response(response)

252

253

254# class MalformedRequestTests(ClientApiTestCase):

255# def test_returns_400_bad_request(self) -> None:

256# self.req.set_post_body(b"rubbish")

257# response = client_api(self.req)

258

259# self.assertEqual(response.status, "400 Bad Request")

260# self.assertIn(b"Not a valid CamCOPS API request", response.body)

261

262

263 class OpRegisterPatientTests(ClientApiTestCase):

264 def setUp(self) -> None:

265 super().setUp()

266

267 self.patient = ServerCreatedPatientFactory()

268 self.idnum = ServerCreatedNHSPatientIdNumFactory(patient=self.patient)

269 PatientIdNumIndexEntry.index_idnum(self.idnum, self.dbsession)

270

271 proquint = self.patient.uuid_as_proquint

272

273 self.post_dict[TabletParam.OPERATION] = Operations.REGISTER_PATIENT

274 self.post_dict[TabletParam.PATIENT_PROQUINT] = proquint

275

276 def test_returns_patient_info(self) -> None:

277 reply_dict = self.call_api()

278

279 self.assertEqual(

280 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

281 )

282

283 patient_dict = json.loads(reply_dict[TabletParam.PATIENT_INFO])[0]

284

285 self.assertEqual(

286 patient_dict[TabletParam.SURNAME], self.patient.surname

287 )

288 self.assertEqual(

289 patient_dict[TabletParam.FORENAME], self.patient.forename

290 )

291 self.assertEqual(patient_dict[TabletParam.SEX], self.patient.sex)

292 self.assertEqual(

293 patient_dict[TabletParam.DOB], self.patient.dob.isoformat()

294 )

295 self.assertEqual(

296 patient_dict[TabletParam.ADDRESS], self.patient.address

297 )

298 self.assertEqual(patient_dict[TabletParam.GP], self.patient.gp)

299 self.assertEqual(patient_dict[TabletParam.OTHER], self.patient.other)

300 self.assertEqual(

301 patient_dict[f"idnum{self.idnum.which_idnum}"],

302 self.idnum.idnum_value,

303 )

304

305 def test_creates_user(self) -> None:

306 reply_dict = self.call_api()

307 self.assertEqual(

308 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

309 )

310

311 username = reply_dict[TabletParam.USER]

312 self.assertEqual(

313 username,

314 make_single_user_mode_username(self.device.name, self.patient._pk),

315 )

316 password = reply_dict[TabletParam.PASSWORD]

317 self.assertEqual(len(password), 32)

318

319 valid_chars = string.ascii_letters + string.digits + string.punctuation

320 self.assertTrue(all(c in valid_chars for c in password))

321

322 user = self.dbsession.execute(

323 select(User).where(User.username == username)

324 ).scalar_one()

325 self.assertEqual(user.upload_group, self.patient.group)

326 self.assertTrue(user.auto_generated)

327 self.assertTrue(user.may_register_devices)

328 self.assertTrue(user.may_upload)

329

330 def test_does_not_create_user_when_name_exists(self) -> None:

331 single_user_username = make_single_user_mode_username(

332 self.device.name, self.patient._pk

333 )

334

335 UserFactory(

336 username=single_user_username,

337 password="old password",

338 password__request=self.req,

339 )

340

341 reply_dict = self.call_api()

342

343 self.assertEqual(

344 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

345 )

346

347 username = reply_dict[TabletParam.USER]

348 self.assertEqual(

349 username,

350 make_single_user_mode_username(self.device.name, self.patient._pk),

351 )

352 password = reply_dict[TabletParam.PASSWORD]

353 self.assertEqual(len(password), 32)

354

355 valid_chars = string.ascii_letters + string.digits + string.punctuation

356 self.assertTrue(all(c in valid_chars for c in password))

357

358 user = self.dbsession.execute(

359 select(User).where(User.username == username)

360 ).scalar_one()

361 self.assertEqual(user.upload_group, self.patient.group)

362 self.assertTrue(user.auto_generated)

363 self.assertTrue(user.may_register_devices)

364 self.assertTrue(user.may_upload)

365

366 def test_raises_for_invalid_proquint(self) -> None:

367 self.post_dict[TabletParam.PATIENT_PROQUINT] = "invalid"

368 reply_dict = self.call_api()

369 self.assertEqual(

370 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

371 )

372 self.assertIn(

373 "no patient with access key 'invalid'",

374 reply_dict[TabletParam.ERROR],

375 )

376

377 def test_raises_for_missing_valid_proquint(self) -> None:

378 valid_proquint = "sazom-diliv-navol-hubot-mufur-mamuv-kojus-loluv-v"

379

380 # Error message is same as for invalid proquint so make sure our

381 # test proquint really is valid (should not raise)

382 uuid_from_proquint(valid_proquint)

383

384 self.post_dict[TabletParam.PATIENT_PROQUINT] = valid_proquint

385 reply_dict = self.call_api()

386

387 self.assertEqual(

388 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

389 )

390 self.assertIn(

391 f"no patient with access key '{valid_proquint}'",

392 reply_dict[TabletParam.ERROR],

393 )

394

395 def test_raises_when_no_patient_idnums(self) -> None:

396 # In theory this shouldn't be possible in normal operation as the

397 # patient cannot be created without any idnums

398 patient = ServerCreatedPatientFactory()

399

400 proquint = patient.uuid_as_proquint

401 self.post_dict[TabletParam.PATIENT_PROQUINT] = proquint

402 reply_dict = self.call_api()

403 self.assertEqual(

404 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

405 )

406 self.assertIn(

407 "Patient has no ID numbers", reply_dict[TabletParam.ERROR]

408 )

409

410 def test_raises_when_patient_not_created_on_server(self) -> None:

411 patient = PatientFactory()

412

413 proquint = patient.uuid_as_proquint

414 self.post_dict[TabletParam.PATIENT_PROQUINT] = proquint

415 reply_dict = self.call_api()

416 self.assertEqual(

417 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

418 )

419 self.assertIn(

420 f"no patient with access key '{proquint}'",

421 reply_dict[TabletParam.ERROR],

422 )

423

424 def test_returns_ip_use_flags(self) -> None:

425 ip_use = self.patient.group.ip_use

426

427 reply_dict = self.call_api()

428

429 self.assertEqual(

430 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

431 )

432

433 ip_use_info = json.loads(reply_dict[TabletParam.IP_USE_INFO])

434

435 self.assertEqual(

436 ip_use_info[TabletParam.IP_USE_COMMERCIAL], ip_use.commercial

437 )

438 self.assertEqual(

439 ip_use_info[TabletParam.IP_USE_CLINICAL], ip_use.clinical

440 )

441 self.assertEqual(

442 ip_use_info[TabletParam.IP_USE_EDUCATIONAL], ip_use.educational

443 )

444 self.assertEqual(

445 ip_use_info[TabletParam.IP_USE_RESEARCH], ip_use.research

446 )

447

448

449 class OpGetTaskSchedulesTests(ClientApiTestCase):

450 def test_returns_task_schedules(self) -> None:

451 schedule1 = TaskScheduleFactory(group=self.group)

452 schedule2 = TaskScheduleFactory(group=self.group)

453

454 TaskScheduleItemFactory(

455 task_schedule=schedule1,

456 task_table_name="phq9",

457 due_from=Duration(days=0),

458 due_by=Duration(days=7),

459 )

460 TaskScheduleItemFactory(

461 task_schedule=schedule1,

462 task_table_name="bmi",

463 due_from=Duration(days=0),

464 due_by=Duration(days=8),

465 )

466 TaskScheduleItemFactory(

467 task_schedule=schedule1,

468 task_table_name="phq9",

469 due_from=Duration(days=30),

470 due_by=Duration(days=37),

471 )

472 TaskScheduleItemFactory(

473 task_schedule=schedule1,

474 task_table_name="gmcpq",

475 due_from=Duration(days=30),

476 due_by=Duration(days=38),

477 )

478

479 # This is the patient originally created om the server

480 server_patient = ServerCreatedPatientFactory(_group=self.group)

481 server_idnum = ServerCreatedNHSPatientIdNumFactory(

482 patient=server_patient

483 )

484

485 # This is the same patient but from the device

486 patient = PatientFactory(_group=self.group)

487 idnum = NHSPatientIdNumFactory(

488 patient=patient,

489 which_idnum=server_idnum.which_idnum,

490 idnum_value=server_idnum.idnum_value,

491 )

492 PatientIdNumIndexEntry.index_idnum(idnum, self.dbsession)

493

494 PatientTaskScheduleFactory(

495 patient=server_patient,

496 task_schedule=schedule1,

497 settings={

498 "bmi": {"bmi_key": "bmi_value"},

499 "phq9": {"phq9_key": "phq9_value"},

500 },

501 start_datetime=local(2020, 7, 31),

502 )

503

504 PatientTaskScheduleFactory(

505 patient=server_patient,

506 task_schedule=schedule2,

507 )

508

509 bmi = BmiFactory(

510 patient=patient,

511 when_created=local(2020, 8, 1),

512 )

513 self.assertTrue(bmi.is_complete())

514

515 TaskIndexEntry.index_task(

516 bmi, self.dbsession, indexed_at_utc=Pendulum.utcnow()

517 )

518

519 proquint = server_patient.uuid_as_proquint

520

521 # For type checker

522 assert proquint is not None

523

524 self.post_dict[TabletParam.OPERATION] = Operations.GET_TASK_SCHEDULES

525 self.post_dict[TabletParam.PATIENT_PROQUINT] = proquint

526

527 reply_dict = self.call_api()

528

529 self.assertEqual(

530 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

531 )

532

533 task_schedules = json.loads(reply_dict[TabletParam.TASK_SCHEDULES])

534

535 self.assertEqual(len(task_schedules), 2)

536

537 s = task_schedules[0]

538 self.assertEqual(s[TabletParam.TASK_SCHEDULE_NAME], schedule1.name)

539

540 schedule_items = s[TabletParam.TASK_SCHEDULE_ITEMS]

541 self.assertEqual(len(schedule_items), 4)

542

543 phq9_1_sched = schedule_items[0]

544 self.assertEqual(phq9_1_sched[TabletParam.TABLE], "phq9")

545 self.assertEqual(

546 phq9_1_sched[TabletParam.SETTINGS], {"phq9_key": "phq9_value"}

547 )

548 self.assertEqual(

549 parse(phq9_1_sched[TabletParam.DUE_FROM]), local(2020, 7, 31)

550 )

551 self.assertEqual(

552 parse(phq9_1_sched[TabletParam.DUE_BY]), local(2020, 8, 7)

553 )

554 self.assertFalse(phq9_1_sched[TabletParam.COMPLETE])

555 self.assertFalse(phq9_1_sched[TabletParam.ANONYMOUS])

556

557 bmi_sched = schedule_items[1]

558 self.assertEqual(bmi_sched[TabletParam.TABLE], "bmi")

559 self.assertEqual(

560 bmi_sched[TabletParam.SETTINGS], {"bmi_key": "bmi_value"}

561 )

562 self.assertEqual(

563 parse(bmi_sched[TabletParam.DUE_FROM]), local(2020, 7, 31)

564 )

565 self.assertEqual(

566 parse(bmi_sched[TabletParam.DUE_BY]), local(2020, 8, 8)

567 )

568 self.assertTrue(bmi_sched[TabletParam.COMPLETE])

569 self.assertFalse(bmi_sched[TabletParam.ANONYMOUS])

570

571 phq9_2_sched = schedule_items[2]

572 self.assertEqual(phq9_2_sched[TabletParam.TABLE], "phq9")

573 self.assertEqual(

574 phq9_2_sched[TabletParam.SETTINGS], {"phq9_key": "phq9_value"}

575 )

576 self.assertEqual(

577 parse(phq9_2_sched[TabletParam.DUE_FROM]), local(2020, 8, 30)

578 )

579 self.assertEqual(

580 parse(phq9_2_sched[TabletParam.DUE_BY]), local(2020, 9, 6)

581 )

582 self.assertFalse(phq9_2_sched[TabletParam.COMPLETE])

583 self.assertFalse(phq9_2_sched[TabletParam.ANONYMOUS])

584

585 # GMCPQ

586 gmcpq_sched = schedule_items[3]

587 self.assertTrue(gmcpq_sched[TabletParam.ANONYMOUS])

588

589

590 class OpGetOrCreateSingleUserTests(DemoRequestTestCase):

591 def setUp(self) -> None:

592 super().setUp()

593

594 self.patient = PatientFactory()

595 self.req._debugging_user = UserFactory()

596

597 def test_user_is_added_to_patient_group(self) -> None:

598 user, _ = get_or_create_single_user(self.req, "test", self.patient)

599 self.dbsession.flush()

600

601 self.assertIn(self.patient.group.id, user.group_ids)

602

603 def test_user_is_created_with_username(self) -> None:

604 user, _ = get_or_create_single_user(self.req, "test", self.patient)

605 self.dbsession.flush()

606

607 self.assertEqual(user.username, "test")

608

609 def test_user_is_assigned_password(self) -> None:

610 _, password = get_or_create_single_user(self.req, "test", self.patient)

611 self.dbsession.flush()

612

613 valid_chars = string.ascii_letters + string.digits + string.punctuation

614 self.assertTrue(all(c in valid_chars for c in password))

615

616 def test_user_upload_group_set(self) -> None:

617 user, _ = get_or_create_single_user(self.req, "test", self.patient)

618 self.dbsession.flush()

619

620 self.assertEqual(user.upload_group, self.patient.group)

621

622 def test_user_auto_generated_flag_set(self) -> None:

623 user, _ = get_or_create_single_user(self.req, "test", self.patient)

624 self.dbsession.flush()

625

626 self.assertTrue(user.auto_generated)

627

628 def test_user_is_not_superuser(self) -> None:

629 user, _ = get_or_create_single_user(self.req, "test", self.patient)

630 self.dbsession.flush()

631

632 self.assertFalse(user.superuser)

633

634 def test_single_patient_pk_set(self) -> None:

635 user, _ = get_or_create_single_user(self.req, "test", self.patient)

636 self.dbsession.flush()

637

638 self.assertEqual(user.single_patient_pk, self.patient._pk)

639

640 def test_user_may_register_devices(self) -> None:

641 user, _ = get_or_create_single_user(self.req, "test", self.patient)

642 self.dbsession.flush()

643

644 self.assertTrue(user.user_group_memberships[0].may_register_devices)

645

646 def test_user_may_upload(self) -> None:

647 user, _ = get_or_create_single_user(self.req, "test", self.patient)

648 self.dbsession.flush()

649

650 self.assertTrue(user.user_group_memberships[0].may_upload)

651

652 def test_existing_user_is_updated(self) -> None:

653 existing_user = UserFactory(username="test")

654

655 user, _ = get_or_create_single_user(self.req, "test", self.patient)

656 self.dbsession.flush()

657

658 self.assertEqual(user, existing_user)

659

660

661 class OpUploadEntireDatabaseTests(ClientApiTestCase):

662 def setUp(self) -> None:

663 super().setUp()

664 self.post_dict[TabletParam.OPERATION] = (

665 Operations.UPLOAD_ENTIRE_DATABASE

666 )

667 self.post_dict[TabletParam.FINALIZING] = 1

668

669 def test_fails_if_pknameinfo_is_not_a_dict(self) -> None:

670 self.post_dict[TabletParam.PKNAMEINFO] = json.dumps(

671 [{"key": "valid JSON but list not dict"}]

672 )

673

674 reply_dict = self.call_api()

675

676 self.assertEqual(

677 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

678 )

679 self.assertIn(

680 "PK name info JSON is not a dict", reply_dict[TabletParam.ERROR]

681 )

682

683 def test_fails_if_databasedata_is_not_a_dict(self) -> None:

684 self.post_dict[TabletParam.PKNAMEINFO] = json.dumps(

685 {"key": "valid JSON"}

686 )

687 self.post_dict[TabletParam.DBDATA] = json.dumps(

688 [{"key": "valid JSON but list not dict"}]

689 )

690

691 reply_dict = self.call_api()

692

693 self.assertEqual(

694 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

695 )

696 self.assertIn(

697 "Database data JSON is not a dict", reply_dict[TabletParam.ERROR]

698 )

699

700 def test_fails_if_table_names_do_not_match(self) -> None:

701 self.post_dict[TabletParam.PKNAMEINFO] = json.dumps(

702 {"table1": "", "table2": "", "table3": ""}

703 )

704 self.post_dict[TabletParam.DBDATA] = json.dumps(

705 {"table4": "", "table5": "", "table6": ""}

706 )

707

708 reply_dict = self.call_api()

709

710 self.assertEqual(

711 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

712 )

713 self.assertIn("Table names don't match", reply_dict[TabletParam.ERROR])

714

715 def test_fails_if_table_names_do_not_exist(self) -> None:

716 self.post_dict[TabletParam.PKNAMEINFO] = json.dumps(

717 {"table1": "", "table2": "", "table3": ""}

718 )

719 self.post_dict[TabletParam.DBDATA] = json.dumps(

720 {"table1": "", "table2": "", "table3": ""}

721 )

722

723 reply_dict = self.call_api()

724

725 self.assertEqual(

726 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

727 )

728 self.assertIn(

729 "Attempt to upload nonexistent tables",

730 reply_dict[TabletParam.ERROR],

731 )

732

733 def test_empty_upload_succeeds(self) -> None:

734 pknameinfo = {key: "" for key in CLIENT_TABLE_MAP.keys()}

735 dbdata = {key: "" for key in CLIENT_TABLE_MAP.keys()}

736

737 self.post_dict[TabletParam.PKNAMEINFO] = json.dumps(pknameinfo)

738 self.post_dict[TabletParam.DBDATA] = json.dumps(dbdata)

739

740 reply_dict = self.call_api()

741 self.assertEqual(

742 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

743 )

744

745 def test_upload_row_succeeds(self) -> None:

746 now_utc_string = now("UTC").isoformat()

747 patient = PatientFactory(_device=self.device)

748 bmi_data = {

749 "id": "1",

750 "height_m": "1.83",

751 "mass_kg": "67",

752 "when_created": now_utc_string,

753 "when_last_modified": now_utc_string,

754 "_move_off_tablet": "1",

755 "patient_id": str(patient.id),

756 }

757

758 pknameinfo = {"bmi": "id"}

759 dbdata = {"bmi": [bmi_data]}

760

761 self.post_dict[TabletParam.PKNAMEINFO] = json.dumps(pknameinfo)

762 self.post_dict[TabletParam.DBDATA] = json.dumps(dbdata)

763

764 reply_dict = self.call_api()

765 self.assertEqual(

766 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

767 )

768 bmi = self.dbsession.execute(

769 select(Bmi).where(Bmi.id == 1)

770 ).scalar_one()

771

772 self.assertAlmostEqual(bmi.height_m, 1.83)

773 self.assertAlmostEqual(bmi.mass_kg, 67)

774

775 def test_upload_row_fails_with_no_pkname(self) -> None:

776 now_utc_string = now("UTC").isoformat()

777 patient = PatientFactory(_device=self.device)

778 bmi_data = {

779 "id": "1",

780 "height_m": "1.83",

781 "mass_kg": "67",

782 "when_created": now_utc_string,

783 "when_last_modified": now_utc_string,

784 "_move_off_tablet": "1",

785 "patient_id": str(patient.id),

786 }

787

788 pknameinfo = {"bmi": ""}

789 dbdata = {"bmi": [bmi_data]}

790

791 self.post_dict[TabletParam.PKNAMEINFO] = json.dumps(pknameinfo)

792 self.post_dict[TabletParam.DBDATA] = json.dumps(dbdata)

793

794 reply_dict = self.call_api()

795 self.assertEqual(

796 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

797 )

798 self.assertIn(

799 "Client-side PK name not specified by client for non-empty table",

800 reply_dict[TabletParam.ERROR],

801 msg=reply_dict,

802 )

803

804 def test_empty_upload_flags_existing_for_deletion(self) -> None:

805 patient = PatientFactory(_device=self.device)

806 bmi = BmiFactory(

807 patient=patient,

808 _removal_pending=True,

809 _era=ERA_NOW,

810 _current=True,

811 )

812

813 pknameinfo = {key: "" for key in CLIENT_TABLE_MAP.keys()}

814 dbdata = {key: "" for key in CLIENT_TABLE_MAP.keys()}

815

816 self.post_dict[TabletParam.PKNAMEINFO] = json.dumps(pknameinfo)

817 self.post_dict[TabletParam.DBDATA] = json.dumps(dbdata)

818

819 reply_dict = self.call_api()

820 self.assertEqual(

821 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

822 )

823 self.dbsession.commit()

824

825 self.assertFalse(bmi._current)

826 self.assertFalse(bmi._removal_pending)

827 self.assertTrue(bmi._removing_user_id, self.user.id)

828 self.assertIsNotNone(bmi._when_removed_exact)

829 self.assertIsNotNone(bmi._when_removed_batch_utc)

830

831

832 class OpValidatePatientsTests(ClientApiTestCase):

833 def setUp(self) -> None:

834 super().setUp()

835

836 self.post_dict[TabletParam.OPERATION] = Operations.VALIDATE_PATIENTS

837

838 def test_fails_if_patient_info_is_not_a_list(self) -> None:

839 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps({})

840

841 reply_dict = self.call_api()

842

843 self.assertEqual(

844 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

845 )

846 self.assertIn(

847 "Top-level JSON is not a list", reply_dict[TabletParam.ERROR]

848 )

849

850 def test_succeeds_for_empty_list(self) -> None:

851 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps([])

852

853 reply_dict = self.call_api()

854

855 self.assertEqual(

856 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

857 )

858

859 def test_fails_if_one_patients_info_is_not_a_dict(self) -> None:

860 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps([[]])

861

862 reply_dict = self.call_api()

863

864 self.assertEqual(

865 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

866 )

867 self.assertIn(

868 "Patient JSON is not a dict", reply_dict[TabletParam.ERROR]

869 )

870

871 def test_fails_if_one_patients_info_is_empty(self) -> None:

872 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps([{}])

873

874 reply_dict = self.call_api()

875

876 self.assertEqual(

877 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

878 )

879 self.assertIn("Patient JSON is empty", reply_dict[TabletParam.ERROR])

880

881 def test_fails_if_forename_is_not_a_string(self) -> None:

882 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

883 [{TabletParam.FORENAME: 1}]

884 )

885

886 reply_dict = self.call_api()

887

888 self.assertEqual(

889 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

890 )

891 self.assertIn("non-string: 1", reply_dict[TabletParam.ERROR])

892

893 def test_fails_if_surname_is_not_a_string(self) -> None:

894 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

895 [{TabletParam.SURNAME: 2}]

896 )

897

898 reply_dict = self.call_api()

899

900 self.assertEqual(

901 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

902 )

903 self.assertIn("non-string: 2", reply_dict[TabletParam.ERROR])

904

905 def test_fails_if_sex_is_not_valid(self) -> None:

906 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

907 [{TabletParam.SEX: "Q"}]

908 )

909

910 reply_dict = self.call_api()

911

912 self.assertEqual(

913 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

914 )

915 self.assertIn("Bad sex value: 'Q'", reply_dict[TabletParam.ERROR])

916

917 def test_fails_if_dob_is_not_a_string(self) -> None:

918 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

919 [{TabletParam.DOB: 3}]

920 )

921

922 reply_dict = self.call_api()

923

924 self.assertEqual(

925 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

926 )

927 self.assertIn("non-string: 3", reply_dict[TabletParam.ERROR])

928

929 def test_fails_if_dob_fails_to_parse(self) -> None:

930 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

931 [{TabletParam.DOB: "Yesterday"}]

932 )

933

934 reply_dict = self.call_api()

935

936 self.assertEqual(

937 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

938 )

939 self.assertIn(

940 "Invalid DOB: 'Yesterday'", reply_dict[TabletParam.ERROR]

941 )

942

943 def test_fails_if_email_is_not_a_string(self) -> None:

944 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

945 [{TabletParam.EMAIL: 4}]

946 )

947

948 reply_dict = self.call_api()

949

950 self.assertEqual(

951 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

952 )

953 self.assertIn("non-string: 4", reply_dict[TabletParam.ERROR])

954

955 def test_fails_if_email_invalid(self) -> None:

956 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

957 [{TabletParam.EMAIL: "email"}]

958 )

959

960 reply_dict = self.call_api()

961

962 self.assertEqual(

963 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

964 )

965 self.assertIn(

966 "Bad e-mail address: 'email'", reply_dict[TabletParam.ERROR]

967 )

968

969 def test_fails_if_address_is_not_a_string(self) -> None:

970 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

971 [{TabletParam.ADDRESS: 5}]

972 )

973

974 reply_dict = self.call_api()

975

976 self.assertEqual(

977 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

978 )

979 self.assertIn("non-string: 5", reply_dict[TabletParam.ERROR])

980

981 def test_fails_if_gp_is_not_a_string(self) -> None:

982 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

983 [{TabletParam.GP: 6}]

984 )

985

986 reply_dict = self.call_api()

987

988 self.assertEqual(

989 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

990 )

991 self.assertIn("non-string: 6", reply_dict[TabletParam.ERROR])

992

993 def test_fails_if_other_is_not_a_string(self) -> None:

994 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

995 [{TabletParam.OTHER: 7}]

996 )

997

998 reply_dict = self.call_api()

999

1000 self.assertEqual(

1001 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1002 )

1003 self.assertIn("non-string: 7", reply_dict[TabletParam.ERROR])

1004

1005 def test_fails_if_which_idnum_is_not_an_int(self) -> None:

1006 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

1007 [{f"{TabletParam.IDNUM_PREFIX}foo": 12345}]

1008 )

1009

1010 reply_dict = self.call_api()

1011

1012 self.assertEqual(

1013 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1014 )

1015 self.assertIn(

1016 "Bad idnum key: 'idnumfoo'", reply_dict[TabletParam.ERROR]

1017 )

1018

1019 def test_fails_if_which_idnum_is_not_valid(self) -> None:

1020 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

1021 [{f"{TabletParam.IDNUM_PREFIX}2": 12345}]

1022 )

1023

1024 self.req.valid_which_idnums = [1]

1025

1026 reply_dict = self.call_api()

1027

1028 self.assertEqual(

1029 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1030 )

1031 self.assertIn("Bad ID number type: 2", reply_dict[TabletParam.ERROR])

1032

1033 def test_fails_if_which_idnum_already_seen(self) -> None:

1034 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

1035 [

1036 {

1037 f"{TabletParam.IDNUM_PREFIX}1": 12345,

1038 f"{TabletParam.IDNUM_PREFIX}01": 12345,

1039 }

1040 ]

1041 )

1042

1043 self.req.valid_which_idnums = [1]

1044

1045 reply_dict = self.call_api()

1046

1047 self.assertEqual(

1048 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1049 )

1050 self.assertIn(

1051 "More than one ID number supplied for ID number type 1",

1052 reply_dict[TabletParam.ERROR],

1053 )

1054

1055 def test_fails_if_idnum_not_an_int(self) -> None:

1056 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

1057 [{f"{TabletParam.IDNUM_PREFIX}1": "foo"}]

1058 )

1059

1060 self.req.valid_which_idnums = [1]

1061

1062 reply_dict = self.call_api()

1063

1064 self.assertEqual(

1065 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1066 )

1067 self.assertIn(

1068 "Bad ID number value: 'foo'", reply_dict[TabletParam.ERROR]

1069 )

1070

1071 def test_fails_if_idref_invalid(self) -> None:

1072 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

1073 [{f"{TabletParam.IDNUM_PREFIX}1": 0}]

1074 )

1075

1076 self.req.valid_which_idnums = [1]

1077

1078 reply_dict = self.call_api()

1079

1080 self.assertEqual(

1081 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1082 )

1083 self.assertIn(

1084 "Bad ID number: IdNumReference(idnum_value=0, which_idnum=1)",

1085 reply_dict[TabletParam.ERROR],

1086 )

1087

1088 def test_fails_if_finalizing_is_not_a_bool(self) -> None:

1089 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

1090 [{TabletParam.FINALIZING: 123}]

1091 )

1092

1093 reply_dict = self.call_api()

1094

1095 self.assertEqual(

1096 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1097 )

1098 self.assertIn(

1099 "Bad 'finalizing' value: 123", reply_dict[TabletParam.ERROR]

1100 )

1101

1102 def test_fails_for_unknown_json_key(self) -> None:

1103 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

1104 [{"foobar": 123}]

1105 )

1106

1107 reply_dict = self.call_api()

1108

1109 self.assertEqual(

1110 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1111 )

1112 self.assertIn(

1113 "Unknown JSON key: 'foobar'", reply_dict[TabletParam.ERROR]

1114 )

1115

1116 def test_fails_for_missing_finalizing_key(self) -> None:

1117 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

1118 [{TabletParam.SURNAME: "Valid"}] # Needs to have something

1119 )

1120

1121 reply_dict = self.call_api()

1122

1123 self.assertEqual(

1124 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1125 )

1126 self.assertIn(

1127 "Missing 'finalizing' JSON key", reply_dict[TabletParam.ERROR]

1128 )

1129

1130 def test_fails_when_candidate_invalid_for_group(self) -> None:

1131 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

1132 [

1133 {

1134 TabletParam.SURNAME: "Valid", # Needs to have something

1135 TabletParam.FINALIZING: True,

1136 }

1137 ]

1138 )

1139

1140 mock_invalid = mock.Mock(return_value=(False, "Mock reason"))

1141

1142 with mock.patch.multiple(

1143 "camcops_server.cc_modules.client_api",

1144 is_candidate_patient_valid_for_group=mock_invalid,

1145 ):

1146 reply_dict = self.call_api()

1147

1148 self.assertEqual(

1149 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1150 )

1151 self.assertIn("Invalid patient", reply_dict[TabletParam.ERROR])

1152 self.assertIn("Mock reason", reply_dict[TabletParam.ERROR])

1153

1154 def test_fails_when_candidate_invalid_for_restricted_user(self) -> None:

1155 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

1156 [

1157 {

1158 TabletParam.SURNAME: "Valid", # Needs to have something

1159 TabletParam.FINALIZING: True,

1160 }

1161 ]

1162 )

1163

1164 mock_valid = mock.Mock(return_value=(True, ""))

1165 mock_invalid = mock.Mock(return_value=(False, "Mock reason"))

1166

1167 with mock.patch.multiple(

1168 "camcops_server.cc_modules.client_api",

1169 is_candidate_patient_valid_for_group=mock_valid,

1170 is_candidate_patient_valid_for_restricted_user=mock_invalid,

1171 ):

1172 reply_dict = self.call_api()

1173

1174 self.assertEqual(

1175 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1176 )

1177 self.assertIn("Invalid patient", reply_dict[TabletParam.ERROR])

1178 self.assertIn("Mock reason", reply_dict[TabletParam.ERROR])

1179

1180 def test_succeeds_for_valid_patient(self) -> None:

1181 sex = Fake.en_gb.sex()

1182 dob = Fake.en_gb.consistent_date_of_birth().isoformat()

1183

1184 # All values set for maximum test coverage

1185 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

1186 [

1187 {

1188 TabletParam.FORENAME: Fake.en_gb.forename(sex),

1189 TabletParam.SURNAME: Fake.en_gb.last_name(),

1190 TabletParam.SEX: sex,

1191 TabletParam.DOB: dob,

1192 TabletParam.ADDRESS: Fake.en_gb.address(),

1193 TabletParam.GP: Fake.en_gb.name(),

1194 TabletParam.OTHER: Fake.en_us.paragraph(),

1195 TabletParam.EMAIL: Fake.en_gb.email(),

1196 TabletParam.FINALIZING: True,

1197 }

1198 ]

1199 )

1200

1201 mock_valid = mock.Mock(return_value=(True, ""))

1202

1203 with mock.patch.multiple(

1204 "camcops_server.cc_modules.client_api",

1205 is_candidate_patient_valid_for_group=mock_valid,

1206 is_candidate_patient_valid_for_restricted_user=mock_valid,

1207 ):

1208 reply_dict = self.call_api()

1209

1210 self.assertEqual(

1211 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

1212 )

1213

1214 def test_succeeds_for_empty_dob(self) -> None:

1215 # All values set for maximum test coverage

1216 self.post_dict[TabletParam.PATIENT_INFO] = json.dumps(

1217 [

1218 {

1219 TabletParam.DOB: "",

1220 TabletParam.FINALIZING: True,

1221 }

1222 ]

1223 )

1224

1225 mock_valid = mock.Mock(return_value=(True, ""))

1226

1227 with mock.patch.multiple(

1228 "camcops_server.cc_modules.client_api",

1229 is_candidate_patient_valid_for_group=mock_valid,

1230 is_candidate_patient_valid_for_restricted_user=mock_valid,

1231 ):

1232 reply_dict = self.call_api()

1233

1234 self.assertEqual(

1235 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

1236 )

1237

1238

1239 class OpWhichKeysToSendTests(ClientApiTestCase):

1240 def setUp(self) -> None:

1241 super().setUp()

1242

1243 self.post_dict[TabletParam.OPERATION] = Operations.WHICH_KEYS_TO_SEND

1244 self.post_dict[TabletParam.PKNAME] = "id"

1245

1246 def test_non_existent_table_rejected(self) -> None:

1247 self.post_dict[TabletParam.TABLE] = "nonexistent_table"

1248 reply_dict = self.call_api()

1249 self.assertEqual(

1250 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1251 )

1252 self.assertEqual(

1253 reply_dict[TabletParam.ERROR],

1254 "Invalid client table name: nonexistent_table",

1255 msg=reply_dict,

1256 )

1257

1258 def test_table_rejected_if_client_too_old(self) -> None:

1259 self.post_dict[TabletParam.CAMCOPS_VERSION] = "2.0.0"

1260 self.post_dict[TabletParam.TABLE] = "table_1"

1261 mock_tables = mock.Mock(

1262 return_value={

1263 "table_1": Version("2.0.1"),

1264 }

1265 )

1266 mock_client_table_map = {"table_1": mock.Mock()}

1267 with mock.patch.multiple(

1268 "camcops_server.cc_modules.client_api",

1269 all_tables_with_min_client_version=mock_tables,

1270 CLIENT_TABLE_MAP=mock_client_table_map,

1271 ):

1272 reply_dict = self.call_api()

1273

1274 self.assertEqual(

1275 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1276 )

1277 self.assertIn(

1278 "Client CamCOPS version 2.0.0 is less than the version (2.0.1)",

1279 reply_dict[TabletParam.ERROR],

1280 msg=reply_dict,

1281 )

1282

1283 def test_fails_for_pk_value_date_count_mismatch(self) -> None:

1284 self.post_dict[TabletParam.TABLE] = "bmi"

1285 self.post_dict[TabletParam.PKVALUES] = "1"

1286 self.post_dict[TabletParam.DATEVALUES] = ""

1287

1288 reply_dict = self.call_api()

1289

1290 self.assertEqual(

1291 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1292 )

1293 self.assertIn("Number of PK values", reply_dict[TabletParam.ERROR])

1294 self.assertIn(

1295 "doesn't match number of dates", reply_dict[TabletParam.ERROR]

1296 )

1297

1298 def test_fails_for_pk_value_move_off_tablet_count_mismatch(self) -> None:

1299 self.post_dict[TabletParam.TABLE] = "bmi"

1300 self.post_dict[TabletParam.PKVALUES] = "1,2"

1301 self.post_dict[TabletParam.DATEVALUES] = "2025-01-23,2025-01-24"

1302 self.post_dict[TabletParam.MOVE_OFF_TABLET_VALUES] = "1"

1303

1304 reply_dict = self.call_api()

1305

1306 self.assertEqual(

1307 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1308 )

1309 self.assertIn(

1310 "Number of move-off-tablet values", reply_dict[TabletParam.ERROR]

1311 )

1312 self.assertIn(

1313 "doesn't match number of PKs", reply_dict[TabletParam.ERROR]

1314 )

1315

1316 def test_fails_for_non_integer_client_pk(self) -> None:

1317 self.post_dict[TabletParam.TABLE] = "bmi"

1318 self.post_dict[TabletParam.PKVALUES] = "1,strawberry"

1319 self.post_dict[TabletParam.DATEVALUES] = "2025-01-23,2025-01-24"

1320 self.post_dict[TabletParam.MOVE_OFF_TABLET_VALUES] = "1,1"

1321

1322 reply_dict = self.call_api()

1323

1324 self.assertEqual(

1325 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1326 )

1327 self.assertIn(

1328 "Bad (non-integer) client PK", reply_dict[TabletParam.ERROR]

1329 )

1330

1331 def test_fails_for_missing_date_time(self) -> None:

1332 self.post_dict[TabletParam.TABLE] = "bmi"

1333 self.post_dict[TabletParam.PKVALUES] = "1"

1334 self.post_dict[TabletParam.DATEVALUES] = "null"

1335 self.post_dict[TabletParam.MOVE_OFF_TABLET_VALUES] = "1"

1336

1337 reply_dict = self.call_api()

1338

1339 self.assertEqual(

1340 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1341 )

1342 self.assertIn("Missing date/time", reply_dict[TabletParam.ERROR])

1343

1344 def test_fails_for_bad_date_time(self) -> None:

1345 self.post_dict[TabletParam.TABLE] = "bmi"

1346 self.post_dict[TabletParam.PKVALUES] = "1"

1347 self.post_dict[TabletParam.DATEVALUES] = "Tuesday"

1348 self.post_dict[TabletParam.MOVE_OFF_TABLET_VALUES] = "1"

1349

1350 reply_dict = self.call_api()

1351

1352 self.assertEqual(

1353 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1354 )

1355 self.assertIn("Bad date/time", reply_dict[TabletParam.ERROR])

1356

1357 def test_succeeds_for_valid_values(self) -> None:

1358 self.post_dict[TabletParam.TABLE] = "bmi"

1359 self.post_dict[TabletParam.PKVALUES] = "123"

1360 self.post_dict[TabletParam.DATEVALUES] = "2025-01-23"

1361 self.post_dict[TabletParam.MOVE_OFF_TABLET_VALUES] = "1"

1362

1363 reply_dict = self.call_api()

1364

1365 self.assertEqual(

1366 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

1367 )

1368 self.assertEqual(reply_dict[TabletParam.RESULT], "123", msg=reply_dict)

1369

1370 def test_succeeds_for_existing_record(self) -> None:

1371 self.post_dict[TabletParam.TABLE] = "bmi"

1372 patient = PatientFactory(_device=self.device)

1373 bmi = BmiFactory(id=123, patient=patient, _era=ERA_NOW)

1374

1375 self.post_dict[TabletParam.PKVALUES] = f"{bmi.id}"

1376 self.post_dict[TabletParam.DATEVALUES] = "2025-01-23"

1377 self.post_dict[TabletParam.MOVE_OFF_TABLET_VALUES] = "1"

1378

1379 reply_dict = self.call_api()

1380

1381 self.assertEqual(

1382 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

1383 )

1384 self.assertEqual(reply_dict[TabletParam.RESULT], "123", msg=reply_dict)

1385

1386 def test_succeeds_for_unmodified_record_marked_for_preservation(

1387 self,

1388 ) -> None:

1389 time_now = local(2025, 1, 26)

1390

1391 patient = PatientFactory(_device=self.device)

1392 bmi = BmiFactory(

1393 id=123,

1394 patient=patient,

1395 _era=ERA_NOW,

1396 when_last_modified=time_now,

1397 _move_off_tablet=False,

1398 )

1399

1400 self.post_dict[TabletParam.TABLE] = "bmi"

1401 self.post_dict[TabletParam.PKVALUES] = f"{bmi.id}"

1402 self.post_dict[TabletParam.DATEVALUES] = time_now.isoformat()

1403 self.post_dict[TabletParam.MOVE_OFF_TABLET_VALUES] = "1"

1404

1405 reply_dict = self.call_api()

1406

1407 self.assertEqual(

1408 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

1409 )

1410 self.assertEqual(reply_dict[TabletParam.RESULT], "", msg=reply_dict)

1411 self.dbsession.commit()

1412

1413 self.assertTrue(bmi._move_off_tablet)

1414

1415

1416 class OpDeleteWhereKeyNotTests(ClientApiTestCase):

1417 def setUp(self) -> None:

1418 super().setUp()

1419

1420 self.post_dict[TabletParam.OPERATION] = Operations.DELETE_WHERE_KEY_NOT

1421 self.post_dict[TabletParam.TABLE] = "bmi"

1422 self.post_dict[TabletParam.PKNAME] = "id"

1423

1424 def test_records_not_specified_marked_for_removal(self) -> None:

1425 patient = PatientFactory(_device=self.device)

1426 bmis = BmiFactory.create_batch(

1427 3,

1428 patient=patient,

1429 _removal_pending=False,

1430 _era=ERA_NOW,

1431 )

1432

1433 self.post_dict[TabletParam.PKVALUES] = f"{bmis[0].id},{bmis[1].id}"

1434 reply_dict = self.call_api()

1435

1436 self.assertEqual(

1437 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

1438 )

1439 self.assertEqual(

1440 reply_dict[TabletParam.RESULT], "Trimmed", msg=reply_dict

1441 )

1442

1443 self.dbsession.commit()

1444

1445 self.assertFalse(bmis[0]._removal_pending)

1446 self.assertFalse(bmis[1]._removal_pending)

1447 self.assertTrue(bmis[2]._removal_pending)

1448

1449

1450 class OpStartPreservationTests(ClientApiTestCase):

1451 def setUp(self) -> None:

1452 super().setUp()

1453

1454 self.post_dict[TabletParam.OPERATION] = Operations.START_PRESERVATION

1455 self.post_dict[TabletParam.TABLE] = "bmi"

1456 self.post_dict[TabletParam.PKNAME] = "id"

1457

1458 def test_device_currently_preserving(self) -> None:

1459 self.assertFalse(self.device.currently_preserving)

1460

1461 patient = PatientFactory(_device=self.device)

1462 bmi = BmiFactory(

1463 id=123,

1464 patient=patient,

1465 _era=ERA_NOW,

1466 )

1467 self.post_dict[TabletParam.PKVALUES] = f"{bmi.id}"

1468 reply_dict = self.call_api()

1469

1470 self.assertEqual(

1471 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

1472 )

1473 self.assertEqual(

1474 reply_dict[TabletParam.RESULT], "STARTPRESERVATION", msg=reply_dict

1475 )

1476

1477 self.dbsession.commit()

1478 self.assertTrue(self.device.currently_preserving)

1479

1480 def test_marks_table_dirty(self) -> None:

1481 self.assertIsNone(

1482 self.dbsession.execute(

1483 select(DirtyTable).where(DirtyTable.tablename == "bmi")

1484 ).scalar_one_or_none()

1485 )

1486

1487 patient = PatientFactory(_device=self.device)

1488 bmi = BmiFactory(

1489 id=123,

1490 patient=patient,

1491 _era=ERA_NOW,

1492 )

1493 self.post_dict[TabletParam.PKVALUES] = f"{bmi.id}"

1494 reply_dict = self.call_api()

1495

1496 self.assertEqual(

1497 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

1498 )

1499 self.assertEqual(

1500 reply_dict[TabletParam.RESULT], "STARTPRESERVATION", msg=reply_dict

1501 )

1502

1503 self.dbsession.commit()

1504

1505 self.assertIsNotNone(

1506 self.dbsession.execute(

1507 select(DirtyTable).where(DirtyTable.tablename == "bmi")

1508 ).scalar_one_or_none()

1509 )

1510

1511

1512 class OpUploadEmptyTablesTests(ClientApiTestCase):

1513 def setUp(self) -> None:

1514 super().setUp()

1515

1516 self.post_dict[TabletParam.OPERATION] = Operations.UPLOAD_EMPTY_TABLES

1517 self.post_dict[TabletParam.TABLES] = "bmi,phq9"

1518

1519 def test_all_records_flagged_as_deleted(self) -> None:

1520 patient = PatientFactory(_device=self.device)

1521 bmi = BmiFactory(

1522 patient=patient,

1523 _era=ERA_NOW,

1524 _current=True,

1525 _removal_pending=False,

1526 )

1527 phq9 = Phq9Factory(

1528 patient=patient,

1529 _era=ERA_NOW,

1530 _current=True,

1531 _removal_pending=False,

1532 )

1533 reply_dict = self.call_api()

1534

1535 self.assertEqual(

1536 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

1537 )

1538 self.assertEqual(

1539 reply_dict[TabletParam.RESULT],

1540 "UPLOAD-EMPTY-TABLES",

1541 msg=reply_dict,

1542 )

1543 self.dbsession.commit()

1544

1545 self.assertTrue(bmi._removal_pending)

1546 self.assertTrue(phq9._removal_pending)

1547

1548 def test_tables_marked_dirty_if_records_in_current_era(self) -> None:

1549 self.assertIsNone(

1550 self.dbsession.execute(

1551 select(DirtyTable).where(DirtyTable.tablename == "bmi")

1552 ).scalar_one_or_none()

1553 )

1554 self.assertIsNone(

1555 self.dbsession.execute(

1556 select(DirtyTable).where(DirtyTable.tablename == "phq9")

1557 ).scalar_one_or_none()

1558 )

1559

1560 patient = PatientFactory(_device=self.device)

1561 BmiFactory(

1562 patient=patient,

1563 _era=ERA_NOW,

1564 _current=True,

1565 )

1566 Phq9Factory(

1567 patient=patient,

1568 _era=ERA_NOW,

1569 _current=True,

1570 )

1571 reply_dict = self.call_api()

1572

1573 self.assertEqual(

1574 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

1575 )

1576 self.dbsession.commit()

1577 self.assertIsNotNone(

1578 self.dbsession.execute(

1579 select(DirtyTable).where(DirtyTable.tablename == "bmi")

1580 ).scalar_one_or_none()

1581 )

1582 self.assertIsNotNone(

1583 self.dbsession.execute(

1584 select(DirtyTable).where(DirtyTable.tablename == "phq9")

1585 ).scalar_one_or_none()

1586 )

1587

1588 def test_tables_marked_clean_if_no_records_in_current_era(self) -> None:

1589 DirtyTableFactory(tablename="bmi", device_id=self.device.id)

1590 DirtyTableFactory(tablename="phq9", device_id=self.device.id)

1591 self.device.currently_preserving = True

1592 self.device.ongoing_upload_batch_utc = now("UTC")

1593 self.dbsession.add(self.device)

1594 self.dbsession.commit()

1595

1596 reply_dict = self.call_api()

1597

1598 self.assertEqual(

1599 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

1600 )

1601 self.dbsession.commit()

1602 self.assertIsNone(

1603 self.dbsession.execute(

1604 select(DirtyTable).where(DirtyTable.tablename == "bmi")

1605 ).scalar_one_or_none()

1606 )

1607 self.assertIsNone(

1608 self.dbsession.execute(

1609 select(DirtyTable).where(DirtyTable.tablename == "phq9")

1610 ).scalar_one_or_none()

1611 )

1612

1613

1614 class OpUploadRecordTests(ClientApiTestCase):

1615 def setUp(self) -> None:

1616 super().setUp()

1617

1618 self.post_dict[TabletParam.OPERATION] = Operations.UPLOAD_RECORD

1619 self.post_dict[TabletParam.PKNAME] = "id"

1620

1621 def test_upload_inserts_record(self) -> None:

1622 now_utc_string = now("UTC").isoformat()

1623 patient = PatientFactory(_device=self.device)

1624

1625 self.post_dict[TabletParam.TABLE] = "bmi"

1626 self.post_dict[TabletParam.PKVALUES] = "1"

1627 self.post_dict[TabletParam.FIELDS] = ",".join(

1628 [

1629 "id",

1630 "height_m",

1631 "mass_kg",

1632 "when_created",

1633 "when_last_modified",

1634 "_move_off_tablet",

1635 "patient_id",

1636 ]

1637 )

1638 self.post_dict[TabletParam.VALUES] = ",".join(

1639 [

1640 "1",

1641 "1.83",

1642 "67",

1643 now_utc_string,

1644 now_utc_string,

1645 "1",

1646 str(patient.id),

1647 ]

1648 )

1649 reply_dict = self.call_api()

1650

1651 self.assertEqual(

1652 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

1653 )

1654 self.assertEqual(

1655 reply_dict[TabletParam.RESULT],

1656 "UPLOAD-INSERT",

1657 msg=reply_dict,

1658 )

1659 bmi = self.dbsession.execute(select(Bmi)).scalar_one_or_none()

1660 self.assertIsNotNone(bmi)

1661

1662 self.assertAlmostEqual(bmi.height_m, 1.83)

1663 self.assertAlmostEqual(bmi.mass_kg, 67)

1664

1665 def test_upload_updates_record(self) -> None:

1666 now_utc_string = now("UTC").isoformat()

1667 patient = PatientFactory(_device=self.device)

1668 bmi = BmiFactory(

1669 patient=patient,

1670 _era=ERA_NOW,

1671 height_m=1.8,

1672 mass_kg=70,

1673 )

1674

1675 self.post_dict[TabletParam.TABLE] = "bmi"

1676 self.post_dict[TabletParam.PKVALUES] = "1"

1677 self.post_dict[TabletParam.FIELDS] = ",".join(

1678 [

1679 "id",

1680 "height_m",

1681 "mass_kg",

1682 "when_created",

1683 "when_last_modified",

1684 "_move_off_tablet",

1685 "patient_id",

1686 ]

1687 )

1688 self.post_dict[TabletParam.VALUES] = ",".join(

1689 [

1690 str(bmi.id),

1691 "1.83",

1692 "67",

1693 now_utc_string,

1694 now_utc_string,

1695 "1",

1696 str(patient.id),

1697 ]

1698 )

1699 reply_dict = self.call_api()

1700

1701 self.assertEqual(

1702 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

1703 )

1704 self.assertEqual(

1705 reply_dict[TabletParam.RESULT],

1706 "UPLOAD-UPDATE",

1707 msg=reply_dict,

1708 )

1709 new_bmi = self.dbsession.execute(

1710 select(Bmi).where(Bmi._predecessor_pk == bmi._pk)

1711 ).scalar_one_or_none()

1712 self.assertIsNotNone(new_bmi)

1713

1714 self.assertAlmostEqual(new_bmi.height_m, 1.83)

1715 self.assertAlmostEqual(new_bmi.mass_kg, 67)

1716

1717 def test_fails_if_field_is_reserved(self) -> None:

1718 self.post_dict[TabletParam.TABLE] = "bmi"

1719 self.post_dict[TabletParam.PKVALUES] = "1"

1720 self.post_dict[TabletParam.FIELDS] = ",".join(

1721 [

1722 "id",

1723 "_current",

1724 ]

1725 )

1726 self.post_dict[TabletParam.VALUES] = ",".join(

1727 [

1728 "1",

1729 "0",

1730 ]

1731 )

1732 reply_dict = self.call_api()

1733

1734 self.assertEqual(

1735 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1736 )

1737 self.assertIn(

1738 "Reserved field name for table",

1739 reply_dict[TabletParam.ERROR],

1740 msg=reply_dict,

1741 )

1742

1743 def test_fails_if_field_does_not_exist(self) -> None:

1744 self.post_dict[TabletParam.TABLE] = "bmi"

1745 self.post_dict[TabletParam.PKVALUES] = "1"

1746 self.post_dict[TabletParam.FIELDS] = ",".join(

1747 [

1748 "id",

1749 "nonsense",

1750 ]

1751 )

1752 self.post_dict[TabletParam.VALUES] = ",".join(

1753 [

1754 "1",

1755 "0",

1756 ]

1757 )

1758 reply_dict = self.call_api()

1759

1760 self.assertEqual(

1761 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1762 )

1763 self.assertIn(

1764 "Invalid field name for table",

1765 reply_dict[TabletParam.ERROR],

1766 msg=reply_dict,

1767 )

1768

1769 def test_upload_inserts_patient_idnum_record(self) -> None:

1770 now_utc_string = now("UTC").isoformat()

1771 iddef = NHSIdNumDefinitionFactory()

1772 patient = PatientFactory(_device=self.device)

1773

1774 self.post_dict[TabletParam.TABLE] = "patient_idnum"

1775 self.post_dict[TabletParam.PKVALUES] = "1"

1776 self.post_dict[TabletParam.FIELDS] = ",".join(

1777 [

1778 "id",

1779 "which_idnum",

1780 "idnum_value",

1781 "when_last_modified",

1782 "_move_off_tablet",

1783 "patient_id",

1784 ]

1785 )

1786 nhs_number = Fake.en_gb.nhs_number()

1787 self.post_dict[TabletParam.VALUES] = ",".join(

1788 [

1789 "1",

1790 str(iddef.which_idnum),

1791 str(nhs_number),

1792 now_utc_string,

1793 "1",

1794 str(patient.id),

1795 ]

1796 )

1797 reply_dict = self.call_api()

1798

1799 self.assertEqual(

1800 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

1801 )

1802 self.assertEqual(

1803 reply_dict[TabletParam.RESULT],

1804 "UPLOAD-INSERT",

1805 msg=reply_dict,

1806 )

1807 patient_idnum = self.dbsession.execute(

1808 select(PatientIdNum)

1809 ).scalar_one_or_none()

1810 self.assertIsNotNone(patient_idnum)

1811

1812 self.assertEqual(patient_idnum.which_idnum, iddef.which_idnum)

1813 self.assertEqual(patient_idnum.idnum_value, nhs_number)

1814

1815 def test_fails_if_patient_idnum_type_unknown(self) -> None:

1816 now_utc_string = now("UTC").isoformat()

1817 patient = PatientFactory(_device=self.device)

1818

1819 self.post_dict[TabletParam.TABLE] = "patient_idnum"

1820 self.post_dict[TabletParam.PKVALUES] = "1"

1821 self.post_dict[TabletParam.FIELDS] = ",".join(

1822 [

1823 "id",

1824 "which_idnum",

1825 "idnum_value",

1826 "when_last_modified",

1827 "_move_off_tablet",

1828 "patient_id",

1829 ]

1830 )

1831 nhs_number = Fake.en_gb.nhs_number()

1832 self.post_dict[TabletParam.VALUES] = ",".join(

1833 [

1834 "1",

1835 "1",

1836 str(nhs_number),

1837 now_utc_string,

1838 "1",

1839 str(patient.id),

1840 ]

1841 )

1842

1843 reply_dict = self.call_api()

1844

1845 self.assertEqual(

1846 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1847 )

1848 self.assertIn(

1849 "No such ID number type: 1",

1850 reply_dict[TabletParam.ERROR],

1851 msg=reply_dict,

1852 )

1853

1854 def test_fails_if_patient_idnum_invalid(self) -> None:

1855 now_utc_string = now("UTC").isoformat()

1856 iddef = NHSIdNumDefinitionFactory()

1857 patient = PatientFactory(_device=self.device)

1858

1859 self.post_dict[TabletParam.TABLE] = "patient_idnum"

1860 self.post_dict[TabletParam.PKVALUES] = "1"

1861 self.post_dict[TabletParam.FIELDS] = ",".join(

1862 [

1863 "id",

1864 "which_idnum",

1865 "idnum_value",

1866 "when_last_modified",

1867 "_move_off_tablet",

1868 "patient_id",

1869 ]

1870 )

1871 invalid_nhs_number = 123

1872 self.post_dict[TabletParam.VALUES] = ",".join(

1873 [

1874 "1",

1875 str(iddef.which_idnum),

1876 str(invalid_nhs_number),

1877 now_utc_string,

1878 "1",

1879 str(patient.id),

1880 ]

1881 )

1882

1883 reply_dict = self.call_api()

1884

1885 self.assertEqual(

1886 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1887 )

1888 self.assertIn(

1889 f"For ID type {iddef.which_idnum}, ID number "

1890 f"{invalid_nhs_number} is invalid",

1891 reply_dict[TabletParam.ERROR],

1892 msg=reply_dict,

1893 )

1894

1895

1896 class OpUploadTableTests(ClientApiTestCase):

1897 def setUp(self) -> None:

1898 super().setUp()

1899 self.post_dict[TabletParam.OPERATION] = Operations.UPLOAD_TABLE

1900 self.post_dict[TabletParam.TABLE] = "bmi"

1901 self.post_dict[TabletParam.PKNAME] = "id"

1902 self.post_dict[TabletParam.FIELDS] = ",".join(

1903 [

1904 "id",

1905 "height_m",

1906 "mass_kg",

1907 "when_created",

1908 "when_last_modified",

1909 "_move_off_tablet",

1910 "patient_id",

1911 ]

1912 )

1913

1914 def test_table_uploaded(self) -> None:

1915 now_utc_string = now("UTC").isoformat()

1916 patient1 = PatientFactory(_device=self.device)

1917 patient2 = PatientFactory(_device=self.device)

1918

1919 self.post_dict[TabletParam.NRECORDS] = "2"

1920 self.post_dict["record0"] = ",".join(

1921 [

1922 "1",

1923 "1.83",

1924 "67",

1925 now_utc_string,

1926 now_utc_string,

1927 "1",

1928 str(patient1.id),

1929 ]

1930 )

1931 self.post_dict["record1"] = ",".join(

1932 [

1933 "2",

1934 "1.6",

1935 "50",

1936 now_utc_string,

1937 now_utc_string,

1938 "1",

1939 str(patient2.id),

1940 ]

1941 )

1942 reply_dict = self.call_api()

1943

1944 self.assertEqual(

1945 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

1946 )

1947 self.assertEqual(

1948 reply_dict[TabletParam.RESULT],

1949 "Table bmi upload successful",

1950 msg=reply_dict,

1951 )

1952

1953 bmi_1 = self.dbsession.execute(

1954 select(Bmi).where(Bmi.id == 1)

1955 ).scalar_one()

1956 self.assertAlmostEqual(bmi_1.height_m, 1.83)

1957 self.assertAlmostEqual(bmi_1.mass_kg, 67)

1958

1959 bmi_2 = self.dbsession.execute(

1960 select(Bmi).where(Bmi.id == 2)

1961 ).scalar_one()

1962 self.assertAlmostEqual(bmi_2.height_m, 1.6)

1963 self.assertAlmostEqual(bmi_2.mass_kg, 50)

1964

1965 def test_fails_if_nrecords_less_than_zero(self) -> None:

1966 self.post_dict[TabletParam.NRECORDS] = "-1"

1967 reply_dict = self.call_api()

1968 self.assertEqual(

1969 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1970 )

1971 self.assertIn(

1972 reply_dict[TabletParam.ERROR],

1973 f"{TabletParam.NRECORDS}=-1: can't be less than 0",

1974 )

1975

1976 def test_fails_if_fields_do_not_match_values(self) -> None:

1977 self.post_dict[TabletParam.NRECORDS] = "1"

1978 self.post_dict["record0"] = ",".join(["1"])

1979 reply_dict = self.call_api()

1980 self.assertEqual(

1981 reply_dict[TabletParam.SUCCESS], FAILURE_CODE, msg=reply_dict

1982 )

1983 self.assertIn(

1984 reply_dict[TabletParam.ERROR],

1985 "Number of fields in field list (7) "

1986 "doesn't match number of values in record 0 (1)",

1987 )

1988

1989 def test_record_updated(self) -> None:

1990 now_utc_string = now("UTC").isoformat()

1991 patient = PatientFactory(_device=self.device)

1992 bmi = BmiFactory(

1993 patient=patient,

1994 _era=ERA_NOW,

1995 height_m=1.8,

1996 mass_kg=70,

1997 )

1998

1999 self.post_dict[TabletParam.NRECORDS] = "1"

2000 self.post_dict["record0"] = ",".join(

2001 [

2002 str(bmi.id),

2003 "1.83",

2004 "67",

2005 now_utc_string,

2006 now_utc_string,

2007 "1",

2008 str(patient.id),

2009 ]

2010 )

2011 reply_dict = self.call_api()

2012

2013 self.assertEqual(

2014 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2015 )

2016 self.assertEqual(

2017 reply_dict[TabletParam.RESULT],

2018 "Table bmi upload successful",

2019 msg=reply_dict,

2020 )

2021

2022 bmi_1 = self.dbsession.execute(

2023 select(Bmi).where(Bmi._predecessor_pk == bmi._pk)

2024 ).scalar_one()

2025 self.assertAlmostEqual(bmi_1.height_m, 1.83)

2026 self.assertAlmostEqual(bmi_1.mass_kg, 67)

2027

2028 def test_record_flagged_for_deletion(self) -> None:

2029 now_utc_string = now("UTC").isoformat()

2030 patient = PatientFactory(_device=self.device)

2031 bmi = BmiFactory(

2032 patient=patient,

2033 _era=ERA_NOW,

2034 height_m=1.8,

2035 mass_kg=70,

2036 )

2037

2038 # No record for this on the tablet

2039 bmi_to_delete = BmiFactory(

2040 patient=patient,

2041 _era=ERA_NOW,

2042 )

2043 self.assertFalse(bmi_to_delete._removal_pending)

2044

2045 self.post_dict[TabletParam.NRECORDS] = "1"

2046 self.post_dict["record0"] = ",".join(

2047 [

2048 str(bmi.id),

2049 "1.83",

2050 "67",

2051 now_utc_string,

2052 now_utc_string,

2053 "1",

2054 str(patient.id),

2055 ]

2056 )

2057 reply_dict = self.call_api()

2058

2059 self.assertEqual(

2060 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2061 )

2062 self.dbsession.commit()

2063

2064 self.assertTrue(bmi_to_delete._removal_pending)

2065

2066 def test_no_records_marks_table_clean(self) -> None:

2067 self.device.currently_preserving = True

2068 self.device.ongoing_upload_batch_utc = now("UTC")

2069 self.dbsession.add(self.device)

2070 self.dbsession.commit()

2071

2072 DirtyTableFactory(tablename="bmi", device_id=self.device.id)

2073 self.assertIsNotNone(

2074 self.dbsession.execute(

2075 select(DirtyTable).where(DirtyTable.tablename == "bmi")

2076 ).scalar_one_or_none()

2077 )

2078 self.post_dict[TabletParam.NRECORDS] = "0"

2079 reply_dict = self.call_api()

2080

2081 self.assertEqual(

2082 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2083 )

2084

2085 self.assertIsNone(

2086 self.dbsession.execute(

2087 select(DirtyTable).where(DirtyTable.tablename == "bmi")

2088 ).scalar_one_or_none()

2089 )

2090

2091

2092 class OpEndUploadTests(ClientApiTestCase):

2093 def setUp(self) -> None:

2094 super().setUp()

2095 self.post_dict[TabletParam.OPERATION] = Operations.END_UPLOAD

2096 self.device.ongoing_upload_batch_utc = now("UTC")

2097 self.device.uploading_user_id = self.user.id

2098 self.dbsession.add(self.device)

2099 self.dbsession.commit()

2100

2101 def test_updates_added_records(self) -> None:

2102 patient = PatientFactory(_device=self.device)

2103 bmi = BmiFactory(

2104 patient=patient,

2105 _era=ERA_NOW,

2106 _addition_pending=True,

2107 )

2108

2109 DirtyTableFactory(tablename="bmi", device_id=self.device.id)

2110

2111 reply_dict = self.call_api()

2112

2113 self.assertEqual(

2114 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2115 )

2116 self.assertFalse(bmi._addition_pending)

2117 self.assertIsNotNone(bmi._when_added_exact)

2118 self.assertIsNotNone(bmi._when_added_batch_utc)

2119

2120 def test_updates_removed_records(self) -> None:

2121 patient = PatientFactory(_device=self.device)

2122 bmi = BmiFactory(

2123 patient=patient,

2124 _era=ERA_NOW,

2125 _current=True,

2126 _removal_pending=True,

2127 )

2128

2129 DirtyTableFactory(tablename="bmi", device_id=self.device.id)

2130

2131 reply_dict = self.call_api()

2132

2133 self.assertEqual(

2134 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2135 )

2136 self.assertFalse(bmi._current)

2137 self.assertFalse(bmi._removal_pending)

2138 self.assertEqual(bmi._removing_user_id, self.user.id)

2139 self.assertIsNotNone(bmi._when_removed_exact)

2140 self.assertIsNotNone(bmi._when_removed_batch_utc)

2141

2142 def test_updates_preserved_records(self) -> None:

2143 patient = PatientFactory(_device=self.device)

2144 bmi = BmiFactory(

2145 patient=patient,

2146 _era=ERA_NOW,

2147 _move_off_tablet=True,

2148 )

2149

2150 DirtyTableFactory(tablename="bmi", device_id=self.device.id)

2151

2152 reply_dict = self.call_api()

2153

2154 self.assertEqual(

2155 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2156 )

2157 self.assertFalse(bmi._move_off_tablet)

2158 self.assertEqual(bmi._preserving_user_id, self.user.id)

2159 self.assertNotEqual(bmi._era, ERA_NOW)

2160

2161

2162 class OpStartUploadTests(ClientApiTestCase):

2163 def setUp(self) -> None:

2164 super().setUp()

2165 self.post_dict[TabletParam.OPERATION] = Operations.START_UPLOAD

2166

2167 def test_updates_device_batch_utc_details(self) -> None:

2168 reply_dict = self.call_api()

2169

2170 self.assertEqual(

2171 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2172 )

2173 self.dbsession.commit()

2174 self.assertIsNotNone(self.device.last_upload_batch_utc)

2175 self.assertIsNotNone(self.device.ongoing_upload_batch_utc)

2176 self.uploading_user_id = self.user.id

2177

2178 def test_deletes_records_with_addition_pending(self) -> None:

2179 patient = PatientFactory(_device=self.device)

2180 BmiFactory(

2181 patient=patient,

2182 _era=ERA_NOW,

2183 _addition_pending=True,

2184 )

2185 DirtyTableFactory(tablename="bmi", device_id=self.device.id)

2186

2187 reply_dict = self.call_api()

2188

2189 self.assertEqual(

2190 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2191 )

2192 self.dbsession.commit()

2193

2194 self.assertIsNone(

2195 self.dbsession.execute(select(Bmi)).scalar_one_or_none()

2196 )

2197

2198 def test_updates_records_with_removal_pending(self) -> None:

2199 patient = PatientFactory(_device=self.device)

2200 BmiFactory(

2201 patient=patient,

2202 _era=ERA_NOW,

2203 _removal_pending=True,

2204 _when_added_exact=self.req.now,

2205 _when_removed_batch_utc=self.req.now_utc,

2206 _removing_user_id=self.user.id,

2207 _successor_pk=1234,

2208 )

2209 DirtyTableFactory(tablename="bmi", device_id=self.device.id)

2210

2211 reply_dict = self.call_api()

2212

2213 self.assertEqual(

2214 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2215 )

2216 self.dbsession.commit()

2217

2218 bmi = self.dbsession.execute(select(Bmi)).scalar_one_or_none()

2219 self.assertFalse(bmi._removal_pending)

2220 self.assertIsNone(bmi._when_added_exact)

2221 self.assertIsNone(bmi._when_removed_batch_utc)

2222 self.assertIsNone(bmi._removing_user_id)

2223 self.assertIsNone(bmi._successor_pk)

2224

2225 def test_sets_move_off_tablet_field_to_false(self) -> None:

2226 patient = PatientFactory(_device=self.device)

2227 BmiFactory(

2228 patient=patient,

2229 _era=ERA_NOW,

2230 _move_off_tablet=True,

2231 )

2232 DirtyTableFactory(tablename="bmi", device_id=self.device.id)

2233

2234 reply_dict = self.call_api()

2235

2236 self.assertEqual(

2237 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2238 )

2239 self.dbsession.commit()

2240

2241 bmi = self.dbsession.execute(select(Bmi)).scalar_one_or_none()

2242 self.assertFalse(bmi._move_off_tablet)

2243

2244

2245 class OpGetIdInfoTests(ClientApiTestCase):

2246 def setUp(self) -> None:

2247 super().setUp()

2248 self.post_dict[TabletParam.OPERATION] = Operations.GET_ID_INFO

2249

2250 def test_returns_database_title(self) -> None:

2251 with mock.patch.object(

2252 self.req,

2253 "database_title",

2254 "test database",

2255 ):

2256 reply_dict = self.call_api()

2257

2258 self.assertEqual(

2259 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2260 )

2261 self.assertEqual(

2262 reply_dict[TabletParam.DATABASE_TITLE], "test database"

2263 )

2264

2265 def test_returns_upload_policy(self) -> None:

2266 self.group.upload_policy = "sex and anyidnum"

2267 self.dbsession.add(self.group)

2268 self.dbsession.commit()

2269

2270 with mock.patch.object(

2271 self.req,

2272 "database_title",

2273 "test database",

2274 ):

2275 reply_dict = self.call_api()

2276

2277 self.assertEqual(

2278 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2279 )

2280 self.assertEqual(

2281 reply_dict[TabletParam.ID_POLICY_UPLOAD], "sex and anyidnum"

2282 )

2283

2284 def test_returns_finalize_policy(self) -> None:

2285 self.group.finalize_policy = "sex and anyidnum"

2286 self.dbsession.add(self.group)

2287 self.dbsession.commit()

2288

2289 with mock.patch.object(

2290 self.req,

2291 "database_title",

2292 "test database",

2293 ):

2294 reply_dict = self.call_api()

2295

2296 self.assertEqual(

2297 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2298 )

2299 self.assertEqual(

2300 reply_dict[TabletParam.ID_POLICY_FINALIZE], "sex and anyidnum"

2301 )

2302

2303 def test_returns_server_version_string(self) -> None:

2304 with mock.patch.multiple(

2305 "camcops_server.cc_modules.client_api",

2306 CAMCOPS_SERVER_VERSION_STRING="test version",

2307 ):

2308 with mock.patch.object(

2309 self.req,

2310 "database_title",

2311 "test database",

2312 ):

2313 reply_dict = self.call_api()

2314

2315 self.assertEqual(

2316 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2317 )

2318 self.assertEqual(

2319 reply_dict[TabletParam.SERVER_CAMCOPS_VERSION], "test version"

2320 )

2321

2322 def test_returns_idnum_definition(self) -> None:

2323 mock_idnum_definition = mock.Mock(

2324 which_idnum=1,

2325 description="Mock NHS Number",

2326 short_description="Mock NHS#",

2327 validation_method="Mock validation method",

2328 )

2329

2330 with mock.patch.multiple(

2331 self.req,

2332 database_title="test database",

2333 idnum_definitions=[mock_idnum_definition],

2334 ):

2335 reply_dict = self.call_api()

2336

2337 self.assertEqual(

2338 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2339 )

2340 self.assertEqual(

2341 reply_dict["idDescription1"],

2342 "Mock NHS Number",

2343 )

2344 self.assertEqual(

2345 reply_dict["idShortDescription1"],

2346 "Mock NHS#",

2347 )

2348 self.assertEqual(

2349 reply_dict["idValidationMethod1"],

2350 "Mock validation method",

2351 )

2352

2353

2354 class OpCheckUploadUserAndDeviceTests(ClientApiTestCase):

2355 def setUp(self) -> None:

2356 super().setUp()

2357 self.post_dict[TabletParam.OPERATION] = (

2358 Operations.CHECK_UPLOAD_USER_DEVICE

2359 )

2360

2361 def test_succeeds(self) -> None:

2362 reply_dict = self.call_api()

2363

2364 self.assertEqual(

2365 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2366 )

2367

2368

2369 class OpGetAllowedTablesTests(ClientApiTestCase):

2370 def setUp(self) -> None:

2371 super().setUp()

2372 self.post_dict[TabletParam.OPERATION] = Operations.GET_ALLOWED_TABLES

2373

2374 def test_returns_allowed_tables(self) -> None:

2375 mock_task_tables = mock.Mock(

2376 return_value={

2377 "table_1": Version("2.0.0"),

2378 "table_2": Version("2.0.1"),

2379 # These get overridden

2380 "blobs": Version("1.0.0"),

2381 "patient": Version("1.0.0"),

2382 "patient_idnum": Version("1.0.0"),

2383 }

2384 )

2385

2386 with mock.patch.multiple(

2387 "camcops_server.cc_modules.client_api",

2388 all_task_tables_with_min_client_version=mock_task_tables,

2389 MINIMUM_TABLET_VERSION=Version("2.0.2"),

2390 ):

2391 reply_dict = self.call_api()

2392

2393 self.assertEqual(

2394 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2395 )

2396

2397 fields = ",".join(

2398 [

2399 AllowedTablesFieldNames.TABLENAME,

2400 AllowedTablesFieldNames.MIN_CLIENT_VERSION,

2401 ]

2402 )

2403 self.assertEqual(reply_dict[TabletParam.NFIELDS], "2")

2404 self.assertEqual(reply_dict[TabletParam.FIELDS], fields)

2405 self.assertEqual(reply_dict[TabletParam.NRECORDS], "5")

2406 self.assertEqual(reply_dict["record0"], "'table_1','2.0.0'")

2407 self.assertEqual(reply_dict["record1"], "'table_2','2.0.1'")

2408 self.assertEqual(reply_dict["record2"], "'blobs','2.0.2'")

2409 self.assertEqual(reply_dict["record3"], "'patient','2.0.2'")

2410 self.assertEqual(reply_dict["record4"], "'patient_idnum','2.0.2'")

2411

2412

2413 class OpGetExtraStringsTests(ClientApiTestCase):

2414 def setUp(self) -> None:

2415 super().setUp()

2416 self.post_dict[TabletParam.OPERATION] = Operations.GET_EXTRA_STRINGS

2417

2418 def test_returns_extra_strings(self) -> None:

2419 mock_extra_strings = mock.Mock(

2420 return_value=[

2421 ("task1", "name1", "language1", "value1"),

2422 ("task2", "name2", "language2", "value2"),

2423 ]

2424 )

2425

2426 with mock.patch.object(

2427 self.req,

2428 "get_all_extra_strings",

2429 mock_extra_strings,

2430 ):

2431 reply_dict = self.call_api()

2432

2433 self.assertEqual(

2434 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2435 )

2436

2437 fields = ",".join(

2438 [

2439 ExtraStringFieldNames.TASK,

2440 ExtraStringFieldNames.NAME,

2441 ExtraStringFieldNames.LANGUAGE,

2442 ExtraStringFieldNames.VALUE,

2443 ]

2444 )

2445 self.assertEqual(reply_dict[TabletParam.NFIELDS], "4")

2446 self.assertEqual(reply_dict[TabletParam.FIELDS], fields)

2447 self.assertEqual(reply_dict[TabletParam.NRECORDS], "2")

2448 self.assertEqual(

2449 reply_dict["record0"], "'task1','name1','language1','value1'"

2450 )

2451 self.assertEqual(

2452 reply_dict["record1"], "'task2','name2','language2','value2'"

2453 )

2454

2455

2456 class OpRegisterDeviceTests(ClientApiTestCase):

2457 def setUp(self) -> None:

2458 super().setUp()

2459 self.post_dict[TabletParam.OPERATION] = Operations.REGISTER

2460

2461 def test_updates_existing_device(self) -> None:

2462 self.assertIsNone(self.device.when_registered_utc)

2463 self.assertIsNone(self.device.registered_by_user_id)

2464 self.post_dict[TabletParam.DEVICE_FRIENDLY_NAME] = "Test device name"

2465

2466 reply_dict = self.call_api()

2467

2468 self.assertEqual(

2469 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2470 )

2471

2472 self.dbsession.commit()

2473

2474 # Server ID info tested elsewhere

2475 self.assertIn(TabletParam.SERVER_CAMCOPS_VERSION, reply_dict)

2476

2477 self.assertEqual(self.device.friendly_name, "Test device name")

2478 self.assertEqual(

2479 self.device.camcops_version,

2480 Version(self.req.tabletsession.tablet_version_str),

2481 )

2482 self.assertIsNotNone(self.device.when_registered_utc)

2483 self.assertEqual(self.device.registered_by_user_id, self.user.id)

2484

2485 def test_registers_new_device(self) -> None:

2486 self.assertIsNone(self.device.when_registered_utc)

2487 self.assertIsNone(self.device.registered_by_user_id)

2488 self.post_dict[TabletParam.DEVICE] = "unregistered"

2489 self.post_dict[TabletParam.DEVICE_FRIENDLY_NAME] = "Test device name"

2490

2491 reply_dict = self.call_api()

2492

2493 self.assertEqual(

2494 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2495 )

2496

2497 self.dbsession.commit()

2498

2499 device = self.dbsession.execute(

2500 select(Device).where(Device.name == "unregistered")

2501 ).scalar_one()

2502

2503 self.assertEqual(device.friendly_name, "Test device name")

2504 self.assertEqual(

2505 device.camcops_version,

2506 Version(self.req.tabletsession.tablet_version_str),

2507 )

2508 self.assertIsNotNone(device.when_registered_utc)

2509 self.assertEqual(device.registered_by_user_id, self.user.id)

2510

2511

2512 class OpCheckDeviceRegisteredTests(ClientApiTestCase):

2513 def setUp(self) -> None:

2514 super().setUp()

2515 self.post_dict[TabletParam.OPERATION] = (

2516 Operations.CHECK_DEVICE_REGISTERED

2517 )

2518

2519 def test_device_registered(self) -> None:

2520 reply_dict = self.call_api()

2521

2522 self.assertEqual(

2523 reply_dict[TabletParam.SUCCESS], SUCCESS_CODE, msg=reply_dict

2524 )