Coverage for app / backend / src / tests / test_galleries.py: 100%

508 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-03 06:18 +0000

1import grpc 

2import pytest 

3from sqlalchemy import select 

4from sqlalchemy.exc import IntegrityError 

5 

6from couchers.db import session_scope 

7from couchers.models import PhotoGallery, PhotoGalleryItem, Upload, User 

8from couchers.proto import galleries_pb2 

9from tests.fixtures.db import generate_user 

10from tests.fixtures.sessions import galleries_session 

11 

12 

13@pytest.fixture(autouse=True) 

14def _(testconfig): 

15 pass 

16 

17 

18def create_upload(session, user_id, filename="test.jpg"): 

19 """Helper to create an upload for testing""" 

20 upload = Upload( 

21 key=f"test_key_{filename}_{user_id}", 

22 filename=filename, 

23 creator_user_id=user_id, 

24 ) 

25 session.add(upload) 

26 session.commit() 

27 return upload.key 

28 

29 

30def test_user_has_profile_gallery(db): 

31 """Each user should have a profile gallery created automatically""" 

32 user1, token1 = generate_user(complete_profile=False) 

33 

34 with session_scope() as session: 

35 user = session.execute(select(User).where(User.id == user1.id)).scalar_one() 

36 assert user.profile_gallery_id is not None 

37 

38 gallery = session.execute(select(PhotoGallery).where(PhotoGallery.id == user.profile_gallery_id)).scalar_one() 

39 assert gallery.owner_user_id == user1.id 

40 

41 

42def test_GetGalleryEditInfo(db): 

43 user1, token1 = generate_user(complete_profile=False) 

44 

45 with galleries_session(token1) as api: 

46 res = api.GetGalleryEditInfo(galleries_pb2.GetGalleryEditInfoReq(gallery_id=user1.profile_gallery_id)) 

47 assert res.gallery_id == user1.profile_gallery_id 

48 assert res.max_photos == 1 

49 assert res.current_photo_count == 0 

50 

51 

52def test_GetGalleryEditInfo_verified_user(db): 

53 user1, token1 = generate_user(complete_profile=False, strong_verification=True) 

54 

55 with galleries_session(token1) as api: 

56 res = api.GetGalleryEditInfo(galleries_pb2.GetGalleryEditInfoReq(gallery_id=user1.profile_gallery_id)) 

57 assert res.gallery_id == user1.profile_gallery_id 

58 assert res.max_photos == 4 

59 assert res.current_photo_count == 0 

60 

61 

62def test_GetGalleryEditInfo_not_owner(db): 

63 user1, token1 = generate_user(complete_profile=False) 

64 user2, token2 = generate_user(complete_profile=False) 

65 

66 with galleries_session(token2) as api: 

67 with pytest.raises(grpc.RpcError) as e: 

68 api.GetGalleryEditInfo(galleries_pb2.GetGalleryEditInfoReq(gallery_id=user1.profile_gallery_id)) 

69 assert e.value.code() == grpc.StatusCode.PERMISSION_DENIED 

70 assert e.value.details() == "You do not have permission to edit this gallery." 

71 

72 

73def test_GetGalleryEditInfo_not_found(db): 

74 user1, token1 = generate_user(complete_profile=False) 

75 

76 with galleries_session(token1) as api: 

77 with pytest.raises(grpc.RpcError) as e: 

78 api.GetGalleryEditInfo(galleries_pb2.GetGalleryEditInfoReq(gallery_id=999999)) 

79 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

80 assert e.value.details() == "Gallery not found." 

81 

82 

83def test_GetGalleryEditInfo_with_photos(db): 

84 user1, token1 = generate_user(complete_profile=False, strong_verification=True) 

85 

86 with session_scope() as session: 

87 keys = [create_upload(session, user1.id, f"photo{i}.jpg") for i in range(3)] 

88 

89 with galleries_session(token1) as api: 

90 for key in keys: 

91 api.AddPhotoToGallery( 

92 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=key) 

93 ) 

94 

95 res = api.GetGalleryEditInfo(galleries_pb2.GetGalleryEditInfoReq(gallery_id=user1.profile_gallery_id)) 

96 assert res.max_photos == 4 

97 assert res.current_photo_count == 3 

98 

99 

100def test_GetGallery_as_owner(db): 

101 user1, token1 = generate_user(complete_profile=False) 

102 

103 with galleries_session(token1) as api: 

104 res = api.GetGallery(galleries_pb2.GetGalleryReq(gallery_id=user1.profile_gallery_id)) 

105 assert res.gallery_id == user1.profile_gallery_id 

106 assert res.can_edit is True 

107 assert len(res.photos) == 0 

108 

109 

110def test_GetGallery_as_non_owner(db): 

111 user1, token1 = generate_user(complete_profile=False) 

112 user2, token2 = generate_user(complete_profile=False) 

113 

114 with galleries_session(token2) as api: 

115 res = api.GetGallery(galleries_pb2.GetGalleryReq(gallery_id=user1.profile_gallery_id)) 

116 assert res.gallery_id == user1.profile_gallery_id 

117 assert res.can_edit is False 

118 assert len(res.photos) == 0 

119 

120 

121def test_GetGallery_not_found(db): 

122 user1, token1 = generate_user(complete_profile=False) 

123 

124 with galleries_session(token1) as api: 

125 with pytest.raises(grpc.RpcError) as e: 

126 api.GetGallery(galleries_pb2.GetGalleryReq(gallery_id=999999)) 

127 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

128 assert e.value.details() == "Gallery not found." 

129 

130 

131def test_AddPhotoToGallery_success(db): 

132 user1, token1 = generate_user(complete_profile=False) 

133 

134 with session_scope() as session: 

135 upload_key = create_upload(session, user1.id) 

136 

137 with galleries_session(token1) as api: 

138 res = api.AddPhotoToGallery( 

139 galleries_pb2.AddPhotoToGalleryReq( 

140 gallery_id=user1.profile_gallery_id, 

141 upload_key=upload_key, 

142 ) 

143 ) 

144 

145 assert len(res.photos) == 1 

146 photo = res.photos[0] 

147 assert photo.full_url 

148 assert photo.thumbnail_url 

149 assert photo.caption == "" 

150 

151 

152def test_AddPhotoToGallery_with_caption(db): 

153 user1, token1 = generate_user(complete_profile=False) 

154 

155 with session_scope() as session: 

156 upload_key = create_upload(session, user1.id) 

157 

158 with galleries_session(token1) as api: 

159 res = api.AddPhotoToGallery( 

160 galleries_pb2.AddPhotoToGalleryReq( 

161 gallery_id=user1.profile_gallery_id, 

162 upload_key=upload_key, 

163 caption="Test caption", 

164 ) 

165 ) 

166 

167 assert len(res.photos) == 1 

168 assert res.photos[0].caption == "Test caption" 

169 

170 

171def test_AddPhotoToGallery_multiple_photos(db): 

172 user1, token1 = generate_user(complete_profile=False, strong_verification=True) 

173 

174 with session_scope() as session: 

175 key1 = create_upload(session, user1.id, "photo1.jpg") 

176 key2 = create_upload(session, user1.id, "photo2.jpg") 

177 key3 = create_upload(session, user1.id, "photo3.jpg") 

178 

179 with galleries_session(token1) as api: 

180 res = api.AddPhotoToGallery( 

181 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=key1) 

182 ) 

183 assert len(res.photos) == 1 

184 

185 res = api.AddPhotoToGallery( 

186 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=key2) 

187 ) 

188 assert len(res.photos) == 2 

189 

190 res = api.AddPhotoToGallery( 

191 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=key3) 

192 ) 

193 assert len(res.photos) == 3 

194 

195 

196def test_AddPhotoToGallery_not_owner(db): 

197 user1, token1 = generate_user(complete_profile=False) 

198 user2, token2 = generate_user(complete_profile=False) 

199 

200 with session_scope() as session: 

201 upload_key = create_upload(session, user2.id) 

202 

203 with galleries_session(token2) as api: 

204 with pytest.raises(grpc.RpcError) as e: 

205 api.AddPhotoToGallery( 

206 galleries_pb2.AddPhotoToGalleryReq( 

207 gallery_id=user1.profile_gallery_id, 

208 upload_key=upload_key, 

209 ) 

210 ) 

211 assert e.value.code() == grpc.StatusCode.PERMISSION_DENIED 

212 assert e.value.details() == "You do not have permission to edit this gallery." 

213 

214 

215def test_AddPhotoToGallery_upload_not_owned(db): 

216 user1, token1 = generate_user(complete_profile=False) 

217 user2, token2 = generate_user(complete_profile=False) 

218 

219 with session_scope() as session: 

220 upload_key = create_upload(session, user2.id) 

221 

222 with galleries_session(token1) as api: 

223 with pytest.raises(grpc.RpcError) as e: 

224 api.AddPhotoToGallery( 

225 galleries_pb2.AddPhotoToGalleryReq( 

226 gallery_id=user1.profile_gallery_id, 

227 upload_key=upload_key, 

228 ) 

229 ) 

230 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

231 assert e.value.details() == "Upload not found or you don't own it." 

232 

233 

234def test_AddPhotoToGallery_max_capacity(db): 

235 user1, token1 = generate_user(complete_profile=False, strong_verification=True) 

236 

237 with session_scope() as session: 

238 keys = [create_upload(session, user1.id, f"photo{i}.jpg") for i in range(5)] 

239 

240 with galleries_session(token1) as api: 

241 for i in range(4): 

242 api.AddPhotoToGallery( 

243 galleries_pb2.AddPhotoToGalleryReq( 

244 gallery_id=user1.profile_gallery_id, 

245 upload_key=keys[i], 

246 ) 

247 ) 

248 

249 with pytest.raises(grpc.RpcError) as e: 

250 api.AddPhotoToGallery( 

251 galleries_pb2.AddPhotoToGalleryReq( 

252 gallery_id=user1.profile_gallery_id, 

253 upload_key=keys[4], 

254 ) 

255 ) 

256 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

257 assert e.value.details() == "The gallery is at maximum capacity and cannot accept more photos." 

258 

259 

260def test_AddPhotoToGallery_duplicate_photo(db): 

261 user1, token1 = generate_user(complete_profile=False, strong_verification=True) 

262 

263 with session_scope() as session: 

264 upload_key = create_upload(session, user1.id) 

265 

266 with galleries_session(token1) as api: 

267 api.AddPhotoToGallery( 

268 galleries_pb2.AddPhotoToGalleryReq( 

269 gallery_id=user1.profile_gallery_id, 

270 upload_key=upload_key, 

271 ) 

272 ) 

273 

274 with pytest.raises(grpc.RpcError) as e: 

275 api.AddPhotoToGallery( 

276 galleries_pb2.AddPhotoToGalleryReq( 

277 gallery_id=user1.profile_gallery_id, 

278 upload_key=upload_key, 

279 ) 

280 ) 

281 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

282 assert e.value.details() == "This photo is already in the gallery." 

283 

284 

285def test_AddPhotoToGallery_gallery_not_found(db): 

286 user1, token1 = generate_user(complete_profile=False) 

287 

288 with session_scope() as session: 

289 upload_key = create_upload(session, user1.id) 

290 

291 with galleries_session(token1) as api: 

292 with pytest.raises(grpc.RpcError) as e: 

293 api.AddPhotoToGallery( 

294 galleries_pb2.AddPhotoToGalleryReq( 

295 gallery_id=999999, 

296 upload_key=upload_key, 

297 ) 

298 ) 

299 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

300 assert e.value.details() == "Gallery not found." 

301 

302 

303def test_RemovePhotoFromGallery_success(db): 

304 user1, token1 = generate_user(complete_profile=False, strong_verification=True) 

305 

306 with session_scope() as session: 

307 key1 = create_upload(session, user1.id, "photo1.jpg") 

308 key2 = create_upload(session, user1.id, "photo2.jpg") 

309 key3 = create_upload(session, user1.id, "photo3.jpg") 

310 

311 with galleries_session(token1) as api: 

312 api.AddPhotoToGallery(galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=key1)) 

313 api.AddPhotoToGallery(galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=key2)) 

314 res = api.AddPhotoToGallery( 

315 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=key3) 

316 ) 

317 

318 item_id = res.photos[1].item_id 

319 

320 res = api.RemovePhotoFromGallery( 

321 galleries_pb2.RemovePhotoFromGalleryReq( 

322 gallery_id=user1.profile_gallery_id, 

323 item_id=item_id, 

324 ) 

325 ) 

326 

327 assert len(res.photos) == 2 

328 

329 

330def test_RemovePhotoFromGallery_not_owner(db): 

331 user1, token1 = generate_user(complete_profile=False) 

332 user2, token2 = generate_user(complete_profile=False) 

333 

334 with session_scope() as session: 

335 upload_key = create_upload(session, user1.id) 

336 

337 with galleries_session(token1) as api: 

338 res = api.AddPhotoToGallery( 

339 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=upload_key) 

340 ) 

341 item_id = res.photos[0].item_id 

342 

343 with galleries_session(token2) as api: 

344 with pytest.raises(grpc.RpcError) as e: 

345 api.RemovePhotoFromGallery( 

346 galleries_pb2.RemovePhotoFromGalleryReq( 

347 gallery_id=user1.profile_gallery_id, 

348 item_id=item_id, 

349 ) 

350 ) 

351 assert e.value.code() == grpc.StatusCode.PERMISSION_DENIED 

352 assert e.value.details() == "You do not have permission to edit this gallery." 

353 

354 

355def test_RemovePhotoFromGallery_item_not_found(db): 

356 user1, token1 = generate_user(complete_profile=False) 

357 

358 with galleries_session(token1) as api: 

359 with pytest.raises(grpc.RpcError) as e: 

360 api.RemovePhotoFromGallery( 

361 galleries_pb2.RemovePhotoFromGalleryReq( 

362 gallery_id=user1.profile_gallery_id, 

363 item_id=999999, 

364 ) 

365 ) 

366 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

367 assert e.value.details() == "Gallery item not found." 

368 

369 

370def test_MovePhoto_to_first(db): 

371 user1, token1 = generate_user(complete_profile=False, strong_verification=True) 

372 

373 with session_scope() as session: 

374 keys = [create_upload(session, user1.id, f"photo{i}.jpg") for i in range(3)] 

375 

376 with galleries_session(token1) as api: 

377 for key in keys: 

378 api.AddPhotoToGallery( 

379 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=key) 

380 ) 

381 

382 res = api.GetGallery(galleries_pb2.GetGalleryReq(gallery_id=user1.profile_gallery_id)) 

383 item_ids = [photo.item_id for photo in res.photos] 

384 

385 # Move last photo to first position 

386 res = api.MovePhoto( 

387 galleries_pb2.MovePhotoReq( 

388 gallery_id=user1.profile_gallery_id, 

389 item_id=item_ids[2], 

390 after_item_id=0, # 0 means first position 

391 ) 

392 ) 

393 

394 # Last photo should now be first 

395 assert res.photos[0].item_id == item_ids[2] 

396 assert res.photos[1].item_id == item_ids[0] 

397 assert res.photos[2].item_id == item_ids[1] 

398 

399 

400def test_MovePhoto_to_middle(db): 

401 user1, token1 = generate_user(complete_profile=False, strong_verification=True) 

402 

403 with session_scope() as session: 

404 keys = [create_upload(session, user1.id, f"photo{i}.jpg") for i in range(3)] 

405 

406 with galleries_session(token1) as api: 

407 for key in keys: 

408 api.AddPhotoToGallery( 

409 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=key) 

410 ) 

411 

412 res = api.GetGallery(galleries_pb2.GetGalleryReq(gallery_id=user1.profile_gallery_id)) 

413 item_ids = [photo.item_id for photo in res.photos] 

414 

415 # Move first photo after second (to middle) 

416 res = api.MovePhoto( 

417 galleries_pb2.MovePhotoReq( 

418 gallery_id=user1.profile_gallery_id, 

419 item_id=item_ids[0], 

420 after_item_id=item_ids[1], 

421 ) 

422 ) 

423 

424 # Order should be: [1, 0, 2] 

425 assert res.photos[0].item_id == item_ids[1] 

426 assert res.photos[1].item_id == item_ids[0] 

427 assert res.photos[2].item_id == item_ids[2] 

428 

429 

430def test_MovePhoto_to_end(db): 

431 user1, token1 = generate_user(complete_profile=False, strong_verification=True) 

432 

433 with session_scope() as session: 

434 keys = [create_upload(session, user1.id, f"photo{i}.jpg") for i in range(3)] 

435 

436 with galleries_session(token1) as api: 

437 for key in keys: 

438 api.AddPhotoToGallery( 

439 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=key) 

440 ) 

441 

442 res = api.GetGallery(galleries_pb2.GetGalleryReq(gallery_id=user1.profile_gallery_id)) 

443 item_ids = [photo.item_id for photo in res.photos] 

444 

445 # Move first photo to end (after last) 

446 res = api.MovePhoto( 

447 galleries_pb2.MovePhotoReq( 

448 gallery_id=user1.profile_gallery_id, 

449 item_id=item_ids[0], 

450 after_item_id=item_ids[2], 

451 ) 

452 ) 

453 

454 # Order should be: [1, 2, 0] 

455 assert res.photos[0].item_id == item_ids[1] 

456 assert res.photos[1].item_id == item_ids[2] 

457 assert res.photos[2].item_id == item_ids[0] 

458 

459 

460def test_MovePhoto_noop(db): 

461 user1, token1 = generate_user(complete_profile=False, strong_verification=True) 

462 

463 with session_scope() as session: 

464 keys = [create_upload(session, user1.id, f"photo{i}.jpg") for i in range(3)] 

465 

466 with galleries_session(token1) as api: 

467 for key in keys: 

468 api.AddPhotoToGallery( 

469 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=key) 

470 ) 

471 

472 res = api.GetGallery(galleries_pb2.GetGalleryReq(gallery_id=user1.profile_gallery_id)) 

473 item_ids = [photo.item_id for photo in res.photos] 

474 

475 # Move photo after itself - should be a no-op 

476 res = api.MovePhoto( 

477 galleries_pb2.MovePhotoReq( 

478 gallery_id=user1.profile_gallery_id, 

479 item_id=item_ids[1], 

480 after_item_id=item_ids[1], 

481 ) 

482 ) 

483 

484 # Order should be unchanged 

485 assert res.photos[0].item_id == item_ids[0] 

486 assert res.photos[1].item_id == item_ids[1] 

487 assert res.photos[2].item_id == item_ids[2] 

488 

489 

490def test_MovePhoto_not_owner(db): 

491 user1, token1 = generate_user(complete_profile=False) 

492 user2, token2 = generate_user(complete_profile=False) 

493 

494 with session_scope() as session: 

495 upload_key = create_upload(session, user1.id) 

496 

497 with galleries_session(token1) as api: 

498 res = api.AddPhotoToGallery( 

499 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=upload_key) 

500 ) 

501 item_id = res.photos[0].item_id 

502 

503 with galleries_session(token2) as api: 

504 with pytest.raises(grpc.RpcError) as e: 

505 api.MovePhoto( 

506 galleries_pb2.MovePhotoReq( 

507 gallery_id=user1.profile_gallery_id, 

508 item_id=item_id, 

509 after_item_id=0, 

510 ) 

511 ) 

512 assert e.value.code() == grpc.StatusCode.PERMISSION_DENIED 

513 assert e.value.details() == "You do not have permission to edit this gallery." 

514 

515 

516def test_MovePhoto_item_not_found(db): 

517 user1, token1 = generate_user(complete_profile=False) 

518 

519 with galleries_session(token1) as api: 

520 with pytest.raises(grpc.RpcError) as e: 

521 api.MovePhoto( 

522 galleries_pb2.MovePhotoReq( 

523 gallery_id=user1.profile_gallery_id, 

524 item_id=999999, 

525 after_item_id=0, 

526 ) 

527 ) 

528 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

529 assert e.value.details() == "Gallery item not found." 

530 

531 

532def test_MovePhoto_after_item_not_found(db): 

533 user1, token1 = generate_user(complete_profile=False) 

534 

535 with session_scope() as session: 

536 upload_key = create_upload(session, user1.id) 

537 

538 with galleries_session(token1) as api: 

539 res = api.AddPhotoToGallery( 

540 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=upload_key) 

541 ) 

542 item_id = res.photos[0].item_id 

543 

544 with pytest.raises(grpc.RpcError) as e: 

545 api.MovePhoto( 

546 galleries_pb2.MovePhotoReq( 

547 gallery_id=user1.profile_gallery_id, 

548 item_id=item_id, 

549 after_item_id=999999, 

550 ) 

551 ) 

552 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

553 assert e.value.details() == "The item to place after was not found." 

554 

555 

556def test_UpdatePhotoCaption_success(db): 

557 user1, token1 = generate_user(complete_profile=False) 

558 

559 with session_scope() as session: 

560 upload_key = create_upload(session, user1.id) 

561 

562 with galleries_session(token1) as api: 

563 res = api.AddPhotoToGallery( 

564 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=upload_key) 

565 ) 

566 item_id = res.photos[0].item_id 

567 

568 res = api.UpdatePhotoCaption( 

569 galleries_pb2.UpdatePhotoCaptionReq( 

570 gallery_id=user1.profile_gallery_id, 

571 item_id=item_id, 

572 caption="New caption", 

573 ) 

574 ) 

575 

576 assert len(res.photos) == 1 

577 assert res.photos[0].caption == "New caption" 

578 

579 

580def test_UpdatePhotoCaption_clear_caption(db): 

581 user1, token1 = generate_user(complete_profile=False) 

582 

583 with session_scope() as session: 

584 upload_key = create_upload(session, user1.id) 

585 

586 with galleries_session(token1) as api: 

587 res = api.AddPhotoToGallery( 

588 galleries_pb2.AddPhotoToGalleryReq( 

589 gallery_id=user1.profile_gallery_id, 

590 upload_key=upload_key, 

591 caption="Initial caption", 

592 ) 

593 ) 

594 item_id = res.photos[0].item_id 

595 

596 res = api.UpdatePhotoCaption( 

597 galleries_pb2.UpdatePhotoCaptionReq( 

598 gallery_id=user1.profile_gallery_id, 

599 item_id=item_id, 

600 caption="", 

601 ) 

602 ) 

603 

604 assert len(res.photos) == 1 

605 assert res.photos[0].caption == "" 

606 

607 

608def test_UpdatePhotoCaption_not_owner(db): 

609 user1, token1 = generate_user(complete_profile=False) 

610 user2, token2 = generate_user(complete_profile=False) 

611 

612 with session_scope() as session: 

613 upload_key = create_upload(session, user1.id) 

614 

615 with galleries_session(token1) as api: 

616 res = api.AddPhotoToGallery( 

617 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=upload_key) 

618 ) 

619 item_id = res.photos[0].item_id 

620 

621 with galleries_session(token2) as api: 

622 with pytest.raises(grpc.RpcError) as e: 

623 api.UpdatePhotoCaption( 

624 galleries_pb2.UpdatePhotoCaptionReq( 

625 gallery_id=user1.profile_gallery_id, 

626 item_id=item_id, 

627 caption="Hacked!", 

628 ) 

629 ) 

630 assert e.value.code() == grpc.StatusCode.PERMISSION_DENIED 

631 assert e.value.details() == "You do not have permission to edit this gallery." 

632 

633 

634def test_UpdatePhotoCaption_item_not_found(db): 

635 user1, token1 = generate_user(complete_profile=False) 

636 

637 with galleries_session(token1) as api: 

638 with pytest.raises(grpc.RpcError) as e: 

639 api.UpdatePhotoCaption( 

640 galleries_pb2.UpdatePhotoCaptionReq( 

641 gallery_id=user1.profile_gallery_id, 

642 item_id=999999, 

643 caption="Test", 

644 ) 

645 ) 

646 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

647 assert e.value.details() == "Gallery item not found." 

648 

649 

650def test_remove_and_readd_photo(db): 

651 user1, token1 = generate_user(complete_profile=False) 

652 

653 with session_scope() as session: 

654 upload_key = create_upload(session, user1.id) 

655 

656 with galleries_session(token1) as api: 

657 res = api.AddPhotoToGallery( 

658 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=upload_key) 

659 ) 

660 item_id = res.photos[0].item_id 

661 

662 res = api.RemovePhotoFromGallery( 

663 galleries_pb2.RemovePhotoFromGalleryReq(gallery_id=user1.profile_gallery_id, item_id=item_id) 

664 ) 

665 assert len(res.photos) == 0 

666 

667 res = api.AddPhotoToGallery( 

668 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=upload_key) 

669 ) 

670 assert len(res.photos) == 1 

671 

672 

673def test_gallery_photo_ordering_preserved(db): 

674 user1, token1 = generate_user(complete_profile=False, strong_verification=True) 

675 

676 with session_scope() as session: 

677 keys = [create_upload(session, user1.id, f"photo{i}.jpg") for i in range(4)] 

678 

679 with galleries_session(token1) as api: 

680 item_ids = [] 

681 for key in keys: 

682 res = api.AddPhotoToGallery( 

683 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=key) 

684 ) 

685 item_ids.append(res.photos[-1].item_id) 

686 

687 res = api.GetGallery(galleries_pb2.GetGalleryReq(gallery_id=user1.profile_gallery_id)) 

688 assert len(res.photos) == 4 

689 for i, photo in enumerate(res.photos): 

690 assert photo.item_id == item_ids[i] 

691 

692 

693def test_database_constraints_upload_uniqueness(db): 

694 user1, token1 = generate_user(complete_profile=False) 

695 

696 with session_scope() as session: 

697 user = session.execute(select(User).where(User.id == user1.id)).scalar_one() 

698 

699 upload = Upload(key="key1", filename="test.jpg", creator_user_id=user.id) 

700 session.add(upload) 

701 session.flush() 

702 

703 gallery_id = user.profile_gallery_id 

704 assert gallery_id 

705 

706 item1 = PhotoGalleryItem(gallery_id=gallery_id, upload_key="key1", position=0.0) 

707 item2 = PhotoGalleryItem(gallery_id=gallery_id, upload_key="key1", position=1.0) 

708 session.add_all([item1, item2]) 

709 

710 with pytest.raises(IntegrityError): 

711 session.flush() 

712 

713 session.rollback() 

714 

715 

716# Avatar photo selection tests 

717 

718 

719def test_get_avatar_upload_returns_first_by_position(db): 

720 """get_avatar_upload should return the upload with the lowest position value""" 

721 from couchers.models.uploads import get_avatar_upload 

722 

723 user1, token1 = generate_user(complete_profile=False, strong_verification=True) 

724 

725 with session_scope() as session: 

726 # Create uploads with specific filenames so we can identify them 

727 keys = [] 

728 for i, filename in enumerate(["first.jpg", "second.jpg", "third.jpg"]): 

729 key = f"key_{filename}_{user1.id}" 

730 upload = Upload(key=key, filename=filename, creator_user_id=user1.id) 

731 session.add(upload) 

732 keys.append(key) 

733 session.commit() 

734 

735 # Add photos in reverse position order (third has lowest position) 

736 with session_scope() as session: 

737 user = session.execute(select(User).where(User.id == user1.id)).scalar_one() 

738 gallery_id = user.profile_gallery_id 

739 assert gallery_id is not None 

740 

741 # Add with positions: third=0.5, first=1.0, second=2.0 

742 session.add(PhotoGalleryItem(gallery_id=gallery_id, upload_key=keys[2], position=0.5)) 

743 session.add(PhotoGalleryItem(gallery_id=gallery_id, upload_key=keys[0], position=1.0)) 

744 session.add(PhotoGalleryItem(gallery_id=gallery_id, upload_key=keys[1], position=2.0)) 

745 session.commit() 

746 

747 with session_scope() as session: 

748 user = session.execute(select(User).where(User.id == user1.id)).scalar_one() 

749 avatar = get_avatar_upload(session, user) 

750 

751 assert avatar is not None 

752 assert avatar.filename == "third.jpg" 

753 

754 

755def test_get_avatar_upload_no_photos(db): 

756 """get_avatar_upload should return None when user has no photos""" 

757 from couchers.models.uploads import get_avatar_upload 

758 

759 user1, token1 = generate_user(complete_profile=False) 

760 

761 with session_scope() as session: 

762 user = session.execute(select(User).where(User.id == user1.id)).scalar_one() 

763 avatar = get_avatar_upload(session, user) 

764 

765 assert avatar is None 

766 

767 

768def test_has_avatar_photo_expression_with_photos(db): 

769 """has_avatar_photo_expression should return True when user has photos""" 

770 from couchers.models.uploads import has_avatar_photo_expression 

771 

772 user1, token1 = generate_user(complete_profile=False) 

773 

774 with session_scope() as session: 

775 upload_key = create_upload(session, user1.id) 

776 

777 with galleries_session(token1) as api: 

778 api.AddPhotoToGallery( 

779 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=upload_key) 

780 ) 

781 

782 with session_scope() as session: 

783 # Test with User class (SQL expression) 

784 result = session.execute( 

785 select(User.id).where(User.id == user1.id).where(has_avatar_photo_expression(User)) 

786 ).scalar_one_or_none() 

787 assert result == user1.id 

788 

789 # Test with User instance 

790 user = session.execute(select(User).where(User.id == user1.id)).scalar_one() 

791 has_photo = session.execute(select(has_avatar_photo_expression(user))).scalar() 

792 assert has_photo is True 

793 

794 

795def test_has_avatar_photo_expression_no_photos(db): 

796 """has_avatar_photo_expression should return False when user has no photos""" 

797 from couchers.models.uploads import has_avatar_photo_expression 

798 

799 user1, token1 = generate_user(complete_profile=False) 

800 

801 with session_scope() as session: 

802 # Test with User class (SQL expression) - should not match 

803 result = session.execute( 

804 select(User.id).where(User.id == user1.id).where(has_avatar_photo_expression(User)) 

805 ).scalar_one_or_none() 

806 assert result is None 

807 

808 # Test with User instance 

809 user = session.execute(select(User).where(User.id == user1.id)).scalar_one() 

810 has_photo = session.execute(select(has_avatar_photo_expression(user))).scalar() 

811 assert has_photo is False 

812 

813 

814def test_avatar_url_via_api_reflects_first_photo(db): 

815 """GetUser should return avatar URL matching the first photo by position""" 

816 from couchers.proto import api_pb2 

817 from tests.fixtures.sessions import api_session 

818 

819 user1, token1 = generate_user(complete_profile=False, strong_verification=True) 

820 user2, token2 = generate_user() 

821 

822 with session_scope() as session: 

823 keys = [] 

824 for i, filename in enumerate(["avatar1.jpg", "avatar2.jpg", "avatar3.jpg"]): 

825 key = f"key_{filename}_{user1.id}" 

826 upload = Upload(key=key, filename=filename, creator_user_id=user1.id) 

827 session.add(upload) 

828 keys.append(key) 

829 session.commit() 

830 

831 # Add photos: avatar2 has lowest position, so it should be the avatar 

832 with session_scope() as session: 

833 user = session.execute(select(User).where(User.id == user1.id)).scalar_one() 

834 gallery_id = user.profile_gallery_id 

835 assert gallery_id is not None 

836 

837 session.add(PhotoGalleryItem(gallery_id=gallery_id, upload_key=keys[1], position=0.5)) # avatar2 

838 session.add(PhotoGalleryItem(gallery_id=gallery_id, upload_key=keys[0], position=1.0)) # avatar1 

839 session.add(PhotoGalleryItem(gallery_id=gallery_id, upload_key=keys[2], position=2.0)) # avatar3 

840 session.commit() 

841 

842 with api_session(token2) as api: 

843 user_pb = api.GetUser(api_pb2.GetUserReq(user=user1.username)) 

844 

845 assert "avatar2.jpg" in user_pb.avatar_url 

846 assert "avatar2.jpg" in user_pb.avatar_thumbnail_url 

847 

848 

849def test_avatar_changes_after_reordering(db): 

850 """Moving a photo to first position should make it the new avatar""" 

851 from couchers.proto import api_pb2 

852 from tests.fixtures.sessions import api_session 

853 

854 user1, token1 = generate_user(complete_profile=False, strong_verification=True) 

855 user2, token2 = generate_user() 

856 

857 with session_scope() as session: 

858 keys = [create_upload(session, user1.id, f"photo{i}.jpg") for i in range(3)] 

859 

860 with galleries_session(token1) as api: 

861 for key in keys: 

862 api.AddPhotoToGallery( 

863 galleries_pb2.AddPhotoToGalleryReq(gallery_id=user1.profile_gallery_id, upload_key=key) 

864 ) 

865 

866 res = api.GetGallery(galleries_pb2.GetGalleryReq(gallery_id=user1.profile_gallery_id)) 

867 item_ids = [photo.item_id for photo in res.photos] 

868 

869 # Check initial avatar (photo0) 

870 with api_session(token2) as api: 

871 user_pb = api.GetUser(api_pb2.GetUserReq(user=user1.username)) 

872 assert "photo0.jpg" in user_pb.avatar_url 

873 

874 # Move photo2 to first position 

875 with galleries_session(token1) as api: 

876 api.MovePhoto( 

877 galleries_pb2.MovePhotoReq( 

878 gallery_id=user1.profile_gallery_id, 

879 item_id=item_ids[2], 

880 after_item_id=0, # 0 means first position 

881 ) 

882 ) 

883 

884 # Check avatar is now photo2 

885 with api_session(token2) as api: 

886 user_pb = api.GetUser(api_pb2.GetUserReq(user=user1.username)) 

887 assert "photo2.jpg" in user_pb.avatar_url 

888 

889 

890def test_avatar_with_negative_positions(db): 

891 """Avatar selection should work correctly with negative position values""" 

892 from couchers.models.uploads import get_avatar_upload 

893 

894 user1, token1 = generate_user(complete_profile=False, strong_verification=True) 

895 

896 with session_scope() as session: 

897 keys = [] 

898 for filename in ["neg.jpg", "zero.jpg", "pos.jpg"]: 

899 key = f"key_{filename}_{user1.id}" 

900 upload = Upload(key=key, filename=filename, creator_user_id=user1.id) 

901 session.add(upload) 

902 keys.append(key) 

903 session.commit() 

904 

905 with session_scope() as session: 

906 user = session.execute(select(User).where(User.id == user1.id)).scalar_one() 

907 gallery_id = user.profile_gallery_id 

908 assert gallery_id is not None 

909 

910 # neg.jpg has the lowest position 

911 session.add(PhotoGalleryItem(gallery_id=gallery_id, upload_key=keys[0], position=-5.0)) 

912 session.add(PhotoGalleryItem(gallery_id=gallery_id, upload_key=keys[1], position=0.0)) 

913 session.add(PhotoGalleryItem(gallery_id=gallery_id, upload_key=keys[2], position=5.0)) 

914 session.commit() 

915 

916 with session_scope() as session: 

917 user = session.execute(select(User).where(User.id == user1.id)).scalar_one() 

918 avatar = get_avatar_upload(session, user) 

919 

920 assert avatar is not None 

921 assert avatar.filename == "neg.jpg" 

922 

923 

924def test_avatar_with_fractional_positions(db): 

925 """Avatar selection should work correctly with fractional position values""" 

926 from couchers.models.uploads import get_avatar_upload 

927 

928 user1, token1 = generate_user(complete_profile=False, strong_verification=True) 

929 

930 with session_scope() as session: 

931 keys = [] 

932 for filename in ["a.jpg", "b.jpg", "c.jpg"]: 

933 key = f"key_{filename}_{user1.id}" 

934 upload = Upload(key=key, filename=filename, creator_user_id=user1.id) 

935 session.add(upload) 

936 keys.append(key) 

937 session.commit() 

938 

939 with session_scope() as session: 

940 user = session.execute(select(User).where(User.id == user1.id)).scalar_one() 

941 gallery_id = user.profile_gallery_id 

942 assert gallery_id is not None 

943 

944 # b.jpg has the lowest position (0.001) 

945 session.add(PhotoGalleryItem(gallery_id=gallery_id, upload_key=keys[0], position=0.5)) 

946 session.add(PhotoGalleryItem(gallery_id=gallery_id, upload_key=keys[1], position=0.001)) 

947 session.add(PhotoGalleryItem(gallery_id=gallery_id, upload_key=keys[2], position=0.999)) 

948 session.commit() 

949 

950 with session_scope() as session: 

951 user = session.execute(select(User).where(User.id == user1.id)).scalar_one() 

952 avatar = get_avatar_upload(session, user) 

953 

954 assert avatar is not None 

955 assert avatar.filename == "b.jpg"