steid

@jamesgill /

Rust39.1 KBCode·Blame·History·Raw
1//! Issues: opening one, reading them, commenting, closing, reopening and editing.
2//!
3//! **Reading follows the repository's visibility; every write is owner-only.** Both
4//! rules live here rather than on the pages, so `/api` and a future transport cannot
5//! reach a different answer than the issues page does.
6//!
7//! The two refusals are deliberately different, matching the split
8//! [`view_repo`](super::view_repo) already sets:
9//!
10//! - A repository the actor may not *see* is `NotFound` (or `Ok(None)` on a read), so a
11//! private repository stays absent rather than merely locked.
12//! - A repository the actor *can* see but does not own is `Forbidden`, because the
13//! repository is public anyway and pretending otherwise would be theatre.
14
15use std::{collections::HashMap, time::SystemTime};
16
17use crate::domain::{
18 Actor, DomainError, Issue, IssueComment, IssueCommentId, IssueId, IssueNumber, IssueState,
19 OrgName, RepoName, Repository, UserId,
20 repository::{
21 IssueCounts, IssueRepository, LabelRepository, MembershipRepository, OrgRepository,
22 RepoRepository, UserRepository,
23 },
24};
25
26use super::{
27 access::{Stores, readable_repo, writable_repo},
28 error::{Error, Result},
29 label::{LabelView, labels_by_issue},
30 paging::{Paged, clamp, offset, page_count},
31};
32
33/// How many issues the repository has in one state.
34fn state_count(counts: IssueCounts, state: IssueState) -> usize {
35 let count = match state {
36 IssueState::Open => counts.open,
37 IssueState::Closed => counts.closed,
38 };
39
40 count as usize
41}
42
43/// How many issues one page of a list shows.
44///
45/// The log's page rather than the refs pages': an issue row is two lines and read, not
46/// scanned past.
47pub const ISSUE_LIMIT: usize = 50;
48
49/// What someone typed into the new-issue form. Raw — validation is [`Issue::new`]'s job.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct NewIssue {
52 pub title: String,
53 pub body: String,
54}
55
56/// An edit to an issue's title and body. The same shape as [`NewIssue`], kept separate
57/// so the two cannot be passed for one another.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct IssueEdit {
60 pub title: String,
61 pub body: String,
62}
63
64/// An issue as it appears in a listing.
65///
66/// The body is absent on purpose: a list row shows one line, and carrying every body
67/// through it would load the whole conversation to render titles.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct IssueSummary {
70 pub number: IssueNumber,
71 pub title: String,
72 pub state: IssueState,
73 /// The author's handle, or `None` if their account is gone. Rendered as "someone"
74 /// rather than left blank — an issue outlives the account that opened it.
75 pub author: Option<OrgName>,
76 pub created_at: SystemTime,
77 pub comments: u64,
78 /// Rendered as pills after the title. By name, like everywhere else labels are
79 /// listed.
80 pub labels: Vec<LabelView>,
81}
82
83/// A repository's issues in one state, plus the counts the toolbar needs for both.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct IssueList {
86 /// Which state was asked for, carried back so a page cannot render the wrong tab as
87 /// current after a bad query string.
88 pub state: IssueState,
89 pub open: u64,
90 pub closed: u64,
91 /// One page of them, newest first, [`ISSUE_LIMIT`] to a page.
92 pub issues: Paged<IssueSummary>,
93 pub viewer_is_owner: bool,
94 /// The label `?label=` asked for, when the repository has one by that name.
95 ///
96 /// `None` both when nothing was asked for and when the name matches no label —
97 /// which is why the counts and the list are empty in the second case rather than
98 /// unfiltered. The page has the raw query value and renders the pill from that, so
99 /// a filter for a label that has been deleted still says what it was filtering on.
100 pub filter: Option<LabelView>,
101}
102
103/// One comment, as a page renders it.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct CommentView {
106 pub author: Option<OrgName>,
107 pub body: String,
108 pub created_at: SystemTime,
109}
110
111/// An issue and its conversation.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct IssueDetail {
114 pub number: IssueNumber,
115 pub title: String,
116 pub body: String,
117 pub state: IssueState,
118 pub author: Option<OrgName>,
119 pub created_at: SystemTime,
120 pub closed_at: Option<SystemTime>,
121 /// Oldest first — a conversation reads forwards.
122 pub comments: Vec<CommentView>,
123 pub viewer_is_owner: bool,
124 /// The labels this issue carries.
125 pub labels: Vec<LabelView>,
126 /// Every label the repository has, for the owner's checkbox list. Empty for anyone
127 /// else — they have no control to fill, and reading them would be a query spent on
128 /// nothing.
129 pub repo_labels: Vec<LabelView>,
130}
131
132// --- Reads --------------------------------------------------------------------------
133
134/// A repository's issues in one state, optionally narrowed to one label.
135///
136/// `Ok(None)` means the repository is not there *or* the viewer may not see it, and the
137/// caller must render both identically — the rule [`view_repo`](super::view_repo) sets.
138///
139/// `filter` is a label **name**, straight off `?label=`. A name the repository has no
140/// label for matches nothing rather than matching everything: silently dropping a
141/// filter it could not resolve would show a list that looks filtered and is not.
142///
143/// **The open and closed counts obey the filter.** A toolbar saying "Open 12" above two
144/// rows is a toolbar you stop reading. Paying for that costs one extra listing — the
145/// other state's — and only when a filter is actually applied.
146#[allow(clippy::too_many_arguments)]
147pub async fn list_issues(
148 actor: &Actor,
149 handle: &OrgName,
150 name: &RepoName,
151 state: IssueState,
152 filter: Option<&str>,
153 page: usize,
154 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
155 issues: &impl IssueRepository,
156 users: &impl UserRepository,
157 labels: &impl LabelRepository,
158) -> Result<Option<IssueList>> {
159 let Some((repo, viewer_is_owner)) = readable_repo(actor, handle, name, stores).await? else {
160 return Ok(None);
161 };
162
163 // One query for every row's pills rather than one per row.
164 let on_issue = labels_by_issue(&repo, labels).await?;
165
166 let filter = match filter {
167 Some(name) => Some(labels.find_by_name(&repo.id, name).await?),
168 None => None,
169 };
170
171 let mut counts = issues.counts(&repo.id).await?;
172
173 // Two paths, because a label filter is not something SQL can answer here: which
174 // labels an issue wears lives in a second table that is already read whole to
175 // render the pills, so a filtered list is narrowed in memory and paged from there.
176 // Unfiltered, the port pages it — which is the case a repository with a thousand
177 // issues actually spends its time in.
178 let mut listed = match filter.is_some() {
179 true => issues.list_by_repo(&repo.id, state, 0, usize::MAX).await?,
180 false => {
181 let total = state_count(counts, state);
182
183 issues
184 .list_by_repo(
185 &repo.id,
186 state,
187 offset(clamp(page, page_count(total, ISSUE_LIMIT)), ISSUE_LIMIT),
188 ISSUE_LIMIT,
189 )
190 .await?
191 }
192 };
193
194 if let Some(wanted) = &filter {
195 let carries = |issue: &Issue| {
196 wanted.as_ref().is_some_and(|wanted| {
197 on_issue
198 .get(&issue.id)
199 .is_some_and(|labels| labels.iter().any(|label| label.id == wanted.id))
200 })
201 };
202
203 let other = match state {
204 IssueState::Open => IssueState::Closed,
205 IssueState::Closed => IssueState::Open,
206 };
207 let others = issues.list_by_repo(&repo.id, other, 0, usize::MAX).await?;
208
209 listed.retain(&carries);
210 let here = listed.len() as u64;
211 let there = others.iter().filter(|issue| carries(issue)).count() as u64;
212
213 counts = match state {
214 IssueState::Open => IssueCounts {
215 open: here,
216 closed: there,
217 },
218 IssueState::Closed => IssueCounts {
219 open: there,
220 closed: here,
221 },
222 };
223 }
224
225 // The filtered list arrived whole and is cut here; the unfiltered one was cut by
226 // the port and arrives as its own page already.
227 let total = match filter.is_some() {
228 true => listed.len(),
229 false => state_count(counts, state),
230 };
231 let paged = match filter.is_some() {
232 true => Paged::slice(listed, page, ISSUE_LIMIT),
233 false => Paged::of(listed, total, page, ISSUE_LIMIT),
234 };
235
236 // One query for every row's comment count rather than one per row.
237 let comments: HashMap<IssueId, u64> =
238 issues.comment_counts(&repo.id).await?.into_iter().collect();
239
240 let mut handles = Handles::default();
241 let mut summaries = Vec::with_capacity(paged.items.len());
242
243 for issue in paged.items {
244 summaries.push(IssueSummary {
245 number: issue.number,
246 title: issue.title,
247 state: issue.state,
248 author: handles.of(&issue.author_id, stores.orgs, users).await?,
249 created_at: issue.created_at,
250 comments: comments.get(&issue.id).copied().unwrap_or(0),
251 labels: on_issue.get(&issue.id).cloned().unwrap_or_default(),
252 });
253 }
254
255 Ok(Some(IssueList {
256 state,
257 open: counts.open,
258 closed: counts.closed,
259 issues: Paged::of(summaries, paged.total, paged.page, ISSUE_LIMIT),
260 viewer_is_owner,
261 filter: filter.flatten().map(LabelView::from),
262 }))
263}
264
265/// One issue and its comments.
266///
267/// `Ok(None)` covers an invisible repository, a missing repository and a number that
268/// does not exist there. A page 404s on all three, which is what it should do: a
269/// number is guessable, and answering differently for one that exists on a private
270/// repository would count them.
271#[allow(clippy::too_many_arguments)]
272pub async fn view_issue(
273 actor: &Actor,
274 handle: &OrgName,
275 name: &RepoName,
276 number: IssueNumber,
277 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
278 issues: &impl IssueRepository,
279 users: &impl UserRepository,
280 labels: &impl LabelRepository,
281) -> Result<Option<IssueDetail>> {
282 let Some((repo, viewer_is_owner)) = readable_repo(actor, handle, name, stores).await? else {
283 return Ok(None);
284 };
285
286 let Some(issue) = issues.find_by_number(&repo.id, number).await? else {
287 return Ok(None);
288 };
289
290 let mut handles = Handles::default();
291 let author = handles.of(&issue.author_id, stores.orgs, users).await?;
292
293 let mut comments = Vec::new();
294 for comment in issues.list_comments(&issue.id).await? {
295 comments.push(CommentView {
296 author: handles.of(&comment.author_id, stores.orgs, users).await?,
297 body: comment.body,
298 created_at: comment.created_at,
299 });
300 }
301
302 Ok(Some(IssueDetail {
303 number: issue.number,
304 title: issue.title,
305 body: issue.body,
306 state: issue.state,
307 author,
308 created_at: issue.created_at,
309 closed_at: issue.closed_at,
310 comments,
311 viewer_is_owner,
312 labels: labels
313 .labels_for_issue(&issue.id)
314 .await?
315 .into_iter()
316 .map(LabelView::from)
317 .collect(),
318 // Only the owner has a control to fill, so only the owner pays for the query.
319 repo_labels: if viewer_is_owner {
320 labels
321 .list_by_repo(&repo.id)
322 .await?
323 .into_iter()
324 .map(LabelView::from)
325 .collect()
326 } else {
327 Vec::new()
328 },
329 }))
330}
331
332// --- Writes -------------------------------------------------------------------------
333
334/// Opens an issue. Owner only.
335///
336/// Returns the created issue because the caller has to redirect to its number, and only
337/// the allocation knows what that is.
338///
339/// The number is allocated **before** the issue is validated, so a rejected title burns
340/// one. That is the trade the sequence's atomicity forces — see
341/// [`RepoRepository::next_number`] — and a gap costs nothing next to a reused number.
342pub async fn create_issue(
343 actor: &Actor,
344 handle: &OrgName,
345 name: &RepoName,
346 spec: &NewIssue,
347 now: SystemTime,
348 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
349 issues: &impl IssueRepository,
350) -> Result<Issue> {
351 let repo = writable_repo(actor, handle, name, stores).await?;
352
353 // Present because `writable_repo` already refused an anonymous actor; unwrapping
354 // would still be a panic waiting for the day that changes.
355 let author_id = actor.user_id().ok_or(DomainError::Forbidden)?.clone();
356
357 let number = stores.repos.next_number(&repo.id).await?;
358 let issue = Issue::new(
359 IssueId::generate(),
360 repo.id.clone(),
361 number,
362 spec.title.clone(),
363 spec.body.clone(),
364 author_id,
365 now,
366 )?;
367
368 issues.save(&issue).await?;
369
370 Ok(issue)
371}
372
373/// Adds a comment to an issue. Owner only.
374///
375/// The issue's `updated_at` moves with it: the conversation advanced even though what
376/// the issue says did not.
377#[allow(clippy::too_many_arguments)]
378pub async fn comment_on_issue(
379 actor: &Actor,
380 handle: &OrgName,
381 name: &RepoName,
382 number: IssueNumber,
383 body: &str,
384 now: SystemTime,
385 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
386 issues: &impl IssueRepository,
387) -> Result<()> {
388 let repo = writable_repo(actor, handle, name, stores).await?;
389 let mut issue = existing_issue(&repo, number, issues).await?;
390 let author_id = actor.user_id().ok_or(DomainError::Forbidden)?.clone();
391
392 let comment = IssueComment::new(
393 IssueCommentId::generate(),
394 issue.id.clone(),
395 author_id,
396 body,
397 now,
398 )?;
399
400 issues.add_comment(&comment).await?;
401
402 issue.touch(now);
403 issues.save(&issue).await?;
404
405 Ok(())
406}
407
408/// Closes or reopens an issue. Owner only.
409///
410/// One use case rather than two, because the two differ only in the state asked for and
411/// splitting them would mean two copies of the same authorization.
412#[allow(clippy::too_many_arguments)]
413pub async fn set_issue_state(
414 actor: &Actor,
415 handle: &OrgName,
416 name: &RepoName,
417 number: IssueNumber,
418 state: IssueState,
419 now: SystemTime,
420 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
421 issues: &impl IssueRepository,
422) -> Result<()> {
423 let repo = writable_repo(actor, handle, name, stores).await?;
424 let mut issue = existing_issue(&repo, number, issues).await?;
425
426 match state {
427 IssueState::Closed => issue.close(now),
428 IssueState::Open => issue.reopen(now),
429 }
430
431 issues.save(&issue).await?;
432
433 Ok(())
434}
435
436/// Changes an issue's title and body. Owner only.
437#[allow(clippy::too_many_arguments)]
438pub async fn edit_issue(
439 actor: &Actor,
440 handle: &OrgName,
441 name: &RepoName,
442 number: IssueNumber,
443 edit: &IssueEdit,
444 now: SystemTime,
445 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
446 issues: &impl IssueRepository,
447) -> Result<()> {
448 let repo = writable_repo(actor, handle, name, stores).await?;
449 let mut issue = existing_issue(&repo, number, issues).await?;
450
451 // Validated before anything is written, so a rejected title leaves the issue exactly
452 // as it was.
453 issue.edit(edit.title.clone(), edit.body.clone(), now)?;
454 issues.save(&issue).await?;
455
456 Ok(())
457}
458
459// --- Shared resolution ----------------------------------------------------------------
460
461/// The issue a write is aimed at, or `NotFound`.
462pub(super) async fn existing_issue(
463 repo: &Repository,
464 number: IssueNumber,
465 issues: &impl IssueRepository,
466) -> Result<Issue> {
467 issues
468 .find_by_number(&repo.id, number)
469 .await?
470 .ok_or(Error::Domain(DomainError::NotFound { entity: "issue" }))
471}
472
473/// Author handles, resolved once each.
474///
475/// A user's handle is their personal organisation's name, which is two lookups away
476/// from a `UserId`. A listing where every issue has the same author — every listing
477/// today — would otherwise pay those two per row.
478#[derive(Debug, Default)]
479pub(crate) struct Handles {
480 known: HashMap<UserId, Option<OrgName>>,
481}
482
483impl Handles {
484 pub(crate) async fn of(
485 &mut self,
486 user_id: &UserId,
487 orgs: &impl OrgRepository,
488 users: &impl UserRepository,
489 ) -> Result<Option<OrgName>> {
490 if let Some(known) = self.known.get(user_id) {
491 return Ok(known.clone());
492 }
493
494 // `None` rather than an error for a user who is gone: an issue outlives the
495 // account that opened it, and a deleted author must not make the page fail.
496 let handle = match users.find_by_id(user_id).await? {
497 Some(user) => orgs
498 .find_by_id(&user.personal_org_id)
499 .await?
500 .map(|org| org.name),
501 None => None,
502 };
503
504 self.known.insert(user_id.clone(), handle.clone());
505
506 Ok(handle)
507 }
508}
509
510#[cfg(test)]
511mod tests {
512 use std::time::Duration;
513
514 use super::*;
515 use crate::{
516 domain::{
517 Email, Membership, MembershipId, OrgId, Organization, PasswordHash, RepoId, Role, User,
518 Visibility,
519 repository::{MembershipRepository, OrgRepository},
520 },
521 infrastructure::repository::{
522 InMemoryIssueRepo, InMemoryLabelRepo, InMemoryMembershipRepo, InMemoryOrgRepo,
523 InMemoryRepoRepo, InMemoryUserRepo,
524 },
525 };
526
527 fn at(seconds: u64) -> SystemTime {
528 SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)
529 }
530
531 struct Fixture {
532 orgs: InMemoryOrgRepo,
533 memberships: InMemoryMembershipRepo,
534 repos: InMemoryRepoRepo,
535 issues: InMemoryIssueRepo,
536 users: InMemoryUserRepo,
537 labels: InMemoryLabelRepo,
538 handle: OrgName,
539 owner: Actor,
540 member: Actor,
541 stranger: Actor,
542 }
543
544 /// One organisation with an owner and a plain member, a public repository `steid`
545 /// and a private one `secret`.
546 async fn fixture() -> Fixture {
547 let orgs = InMemoryOrgRepo::new();
548 let memberships = InMemoryMembershipRepo::new();
549 let repos = InMemoryRepoRepo::new();
550 let users = InMemoryUserRepo::new();
551
552 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
553 orgs.save(&org).await.expect("save org");
554
555 let actor_for = |email: &str, role: Role| {
556 let user = User::new(
557 UserId::generate(),
558 Email::new(email).expect("valid email"),
559 PasswordHash::from_trusted("$argon2id$test"),
560 org.id.clone(),
561 );
562 (user, role)
563 };
564
565 let (owner, _) = actor_for("owner@example.com", Role::Owner);
566 let (member, _) = actor_for("member@example.com", Role::Member);
567
568 for (user, role) in [(&owner, Role::Owner), (&member, Role::Member)] {
569 users.save(user).await.expect("save user");
570 memberships
571 .save(&Membership::new(
572 MembershipId::generate(),
573 org.id.clone(),
574 user.id.clone(),
575 role,
576 ))
577 .await
578 .expect("save membership");
579 }
580
581 for (name, visibility) in [
582 ("steid", Visibility::Public),
583 ("secret", Visibility::Private),
584 ] {
585 repos
586 .save(
587 &Repository::new(
588 RepoId::generate(),
589 org.id.clone(),
590 name,
591 None,
592 visibility,
593 at(1_000),
594 )
595 .expect("valid repo"),
596 )
597 .await
598 .expect("save repo");
599 }
600
601 Fixture {
602 orgs,
603 memberships,
604 repos,
605 issues: InMemoryIssueRepo::new(),
606 users,
607 labels: InMemoryLabelRepo::new(),
608 handle: org.name.clone(),
609 owner: Actor::User(owner.id),
610 member: Actor::User(member.id),
611 stranger: Actor::Anonymous,
612 }
613 }
614
615 fn repo(name: &str) -> RepoName {
616 RepoName::new(name).expect("valid name")
617 }
618
619 /// The id of one of the fixture's repositories, for the ports that take one.
620 async fn repo_id(f: &Fixture, name: &str) -> crate::domain::RepoId {
621 let org = f
622 .orgs
623 .find_by_name(&f.handle)
624 .await
625 .expect("should read")
626 .expect("the org exists");
627
628 f.repos
629 .find_by_org_and_name(&org.id, &repo(name))
630 .await
631 .expect("should read")
632 .expect("the repository exists")
633 .id
634 }
635
636 async fn open(f: &Fixture, name: &str, title: &str) -> Issue {
637 create_issue(
638 &f.owner,
639 &f.handle,
640 &repo(name),
641 &NewIssue {
642 title: title.to_owned(),
643 body: "the body".to_owned(),
644 },
645 at(2_000),
646 &Stores::new(&f.orgs, &f.memberships, &f.repos),
647 &f.issues,
648 )
649 .await
650 .expect("should open")
651 }
652
653 async fn listed(
654 f: &Fixture,
655 actor: &Actor,
656 name: &str,
657 state: IssueState,
658 ) -> Option<IssueList> {
659 listed_page(f, actor, name, state, None, 1).await
660 }
661
662 async fn listed_page(
663 f: &Fixture,
664 actor: &Actor,
665 name: &str,
666 state: IssueState,
667 filter: Option<&str>,
668 page: usize,
669 ) -> Option<IssueList> {
670 list_issues(
671 actor,
672 &f.handle,
673 &repo(name),
674 state,
675 filter,
676 page,
677 &Stores::new(&f.orgs, &f.memberships, &f.repos),
678 &f.issues,
679 &f.users,
680 &f.labels,
681 )
682 .await
683 .expect("should list")
684 }
685
686 #[tokio::test]
687 async fn the_second_page_of_issues_starts_where_the_first_ended() {
688 let f = fixture().await;
689
690 for n in 1..=(ISSUE_LIMIT + 3) {
691 open(&f, "steid", &format!("issue {n}")).await;
692 }
693
694 let first = listed_page(&f, &f.owner, "steid", IssueState::Open, None, 1)
695 .await
696 .expect("visible");
697 let second = listed_page(&f, &f.owner, "steid", IssueState::Open, None, 2)
698 .await
699 .expect("visible");
700
701 assert_eq!(first.issues.items.len(), ISSUE_LIMIT);
702 assert_eq!(second.issues.items.len(), 3);
703 assert_eq!(second.issues.offset(), ISSUE_LIMIT);
704 assert_eq!(second.issues.total, ISSUE_LIMIT + 3);
705 // Newest first, so the second page carries the three oldest.
706 assert_eq!(second.issues.items[2].title, "issue 1");
707 assert!(
708 !first
709 .issues
710 .items
711 .iter()
712 .any(|issue| issue.number == second.issues.items[0].number),
713 "the pages do not overlap"
714 );
715 }
716
717 #[tokio::test]
718 async fn an_issue_page_past_the_end_clamps() {
719 let f = fixture().await;
720 open(&f, "steid", "the only issue").await;
721
722 let list = listed_page(&f, &f.owner, "steid", IssueState::Open, None, 400)
723 .await
724 .expect("visible");
725
726 assert_eq!(list.issues.page, 1);
727 assert_eq!(list.issues.items.len(), 1);
728 }
729
730 #[tokio::test]
731 async fn a_filtered_list_pages_over_what_the_filter_left() {
732 // The path that cannot be paged in SQL: the filter is applied over the labels
733 // table the list already reads, so the page is cut after it.
734 let f = fixture().await;
735 crate::application::create_label(
736 &f.owner,
737 &f.handle,
738 &repo("steid"),
739 &crate::application::NewLabel {
740 name: "bug".to_owned(),
741 colour: crate::domain::LabelColour::Gray,
742 description: None,
743 },
744 at(1_500),
745 &Stores::new(&f.orgs, &f.memberships, &f.repos),
746 &f.labels,
747 )
748 .await
749 .expect("should create");
750
751 let label = f
752 .labels
753 .find_by_name(&repo_id(&f, "steid").await, "bug")
754 .await
755 .expect("should read")
756 .expect("the label exists");
757
758 for n in 1..=(ISSUE_LIMIT + 2) {
759 let issue = open(&f, "steid", &format!("issue {n}")).await;
760
761 // Every other issue wears the label, so the filtered list is genuinely
762 // narrower than the whole and pages differently from it.
763 if n % 2 == 0 {
764 crate::application::set_issue_labels(
765 &f.owner,
766 &f.handle,
767 &repo("steid"),
768 issue.number,
769 std::slice::from_ref(&label.id),
770 &Stores::new(&f.orgs, &f.memberships, &f.repos),
771 &f.issues,
772 &f.labels,
773 )
774 .await
775 .expect("should label");
776 }
777 }
778
779 let first = listed_page(&f, &f.owner, "steid", IssueState::Open, Some("bug"), 1)
780 .await
781 .expect("visible");
782 let second = listed_page(&f, &f.owner, "steid", IssueState::Open, Some("bug"), 2)
783 .await
784 .expect("visible");
785
786 assert_eq!(first.issues.total, 26, "half of 52, labelled");
787 assert_eq!(first.issues.items.len(), ISSUE_LIMIT.min(26));
788 assert!(!first.issues.is_paged(), "26 labelled issues fit one page");
789 assert_eq!(second.issues.page, 1, "and page 2 clamps back to it");
790 assert!(
791 first
792 .issues
793 .items
794 .iter()
795 .all(|issue| issue.labels.iter().any(|pill| pill.name == "bug")),
796 "every row on the page carries the filter's label"
797 );
798 }
799
800 async fn detail(f: &Fixture, actor: &Actor, name: &str, number: u64) -> Option<IssueDetail> {
801 view_issue(
802 actor,
803 &f.handle,
804 &repo(name),
805 IssueNumber::from_trusted(number),
806 &Stores::new(&f.orgs, &f.memberships, &f.repos),
807 &f.issues,
808 &f.users,
809 &f.labels,
810 )
811 .await
812 .expect("should read")
813 }
814
815 fn is_forbidden(error: &Error) -> bool {
816 matches!(error, Error::Domain(DomainError::Forbidden))
817 }
818
819 fn is_not_found(error: &Error) -> bool {
820 matches!(error, Error::Domain(DomainError::NotFound { .. }))
821 }
822
823 // --- numbering ------------------------------------------------------------------
824
825 #[tokio::test]
826 async fn issues_are_numbered_from_one_per_repository() {
827 // The property the whole sequence exists for: `#1` on two repositories is two
828 // different issues, and neither repository's count affects the other's.
829 let f = fixture().await;
830
831 assert_eq!(open(&f, "steid", "first").await.number.get(), 1);
832 assert_eq!(open(&f, "steid", "second").await.number.get(), 2);
833 assert_eq!(open(&f, "secret", "elsewhere").await.number.get(), 1);
834 assert_eq!(open(&f, "steid", "third").await.number.get(), 3);
835 }
836
837 // --- reading --------------------------------------------------------------------
838
839 #[tokio::test]
840 async fn a_listing_is_newest_first_with_counts_for_both_states() {
841 let f = fixture().await;
842 open(&f, "steid", "first").await;
843 open(&f, "steid", "second").await;
844 set_state(&f, 1, IssueState::Closed).await.expect("close");
845
846 let open_list = listed(&f, &f.owner, "steid", IssueState::Open)
847 .await
848 .expect("visible");
849
850 assert_eq!(
851 open_list
852 .issues
853 .items
854 .iter()
855 .map(|issue| issue.number.get())
856 .collect::<Vec<_>>(),
857 vec![2]
858 );
859 assert_eq!((open_list.open, open_list.closed), (1, 1));
860 assert_eq!(open_list.state, IssueState::Open);
861
862 let closed_list = listed(&f, &f.owner, "steid", IssueState::Closed)
863 .await
864 .expect("visible");
865
866 assert_eq!(
867 closed_list
868 .issues
869 .items
870 .iter()
871 .map(|issue| issue.title.as_str())
872 .collect::<Vec<_>>(),
873 vec!["first"]
874 );
875 }
876
877 #[tokio::test]
878 async fn a_listing_names_the_author_and_counts_the_comments() {
879 let f = fixture().await;
880 open(&f, "steid", "Blame is slow").await;
881 comment(&f, &f.owner, 1, "still slow")
882 .await
883 .expect("comment");
884
885 let list = listed(&f, &f.stranger, "steid", IssueState::Open)
886 .await
887 .expect("visible");
888 let row = list.issues.items.first().expect("one row");
889
890 assert_eq!(row.author.as_ref().map(OrgName::as_str), Some("acme"));
891 assert_eq!(row.comments, 1);
892 assert!(
893 !list.viewer_is_owner,
894 "an anonymous visitor is offered no controls"
895 );
896 }
897
898 #[tokio::test]
899 async fn an_anonymous_visitor_reads_a_public_repositorys_issues() {
900 let f = fixture().await;
901 open(&f, "steid", "Blame is slow").await;
902
903 assert!(
904 listed(&f, &f.stranger, "steid", IssueState::Open)
905 .await
906 .is_some()
907 );
908 assert!(detail(&f, &f.stranger, "steid", 1).await.is_some());
909 }
910
911 #[tokio::test]
912 async fn a_private_repositorys_issues_are_absent_rather_than_refused() {
913 // Absent, not forbidden: a refusal would confirm the repository exists.
914 let f = fixture().await;
915 open(&f, "secret", "Blame is slow").await;
916
917 assert!(
918 listed(&f, &f.stranger, "secret", IssueState::Open)
919 .await
920 .is_none()
921 );
922 assert!(detail(&f, &f.stranger, "secret", 1).await.is_none());
923 assert!(
924 listed(&f, &f.member, "secret", IssueState::Open)
925 .await
926 .is_some(),
927 "a member of the organisation may still read them"
928 );
929 }
930
931 #[tokio::test]
932 async fn a_number_that_does_not_exist_reads_as_absent() {
933 let f = fixture().await;
934 open(&f, "steid", "Blame is slow").await;
935
936 assert!(detail(&f, &f.owner, "steid", 2).await.is_none());
937 assert!(
938 detail(&f, &f.owner, "steid", 1).await.is_some(),
939 "the one that does exist still reads"
940 );
941 }
942
943 #[tokio::test]
944 async fn an_issue_carries_its_body_and_its_conversation_forwards() {
945 let f = fixture().await;
946 open(&f, "steid", "Blame is slow").await;
947 comment(&f, &f.owner, 1, "first").await.expect("comment");
948 comment(&f, &f.owner, 1, "second").await.expect("comment");
949
950 let issue = detail(&f, &f.owner, "steid", 1).await.expect("visible");
951
952 assert_eq!(issue.body, "the body");
953 assert_eq!(
954 issue
955 .comments
956 .iter()
957 .map(|comment| comment.body.as_str())
958 .collect::<Vec<_>>(),
959 vec!["first", "second"]
960 );
961 assert_eq!(
962 issue.comments[0].author.as_ref().map(OrgName::as_str),
963 Some("acme")
964 );
965 }
966
967 // --- writing --------------------------------------------------------------------
968
969 async fn set_state(f: &Fixture, number: u64, state: IssueState) -> Result<()> {
970 set_issue_state(
971 &f.owner,
972 &f.handle,
973 &repo("steid"),
974 IssueNumber::from_trusted(number),
975 state,
976 at(3_000),
977 &Stores::new(&f.orgs, &f.memberships, &f.repos),
978 &f.issues,
979 )
980 .await
981 }
982
983 async fn comment(f: &Fixture, actor: &Actor, number: u64, body: &str) -> Result<()> {
984 comment_on_issue(
985 actor,
986 &f.handle,
987 &repo("steid"),
988 IssueNumber::from_trusted(number),
989 body,
990 at(4_000),
991 &Stores::new(&f.orgs, &f.memberships, &f.repos),
992 &f.issues,
993 )
994 .await
995 }
996
997 #[tokio::test]
998 async fn closing_and_reopening_move_an_issue_between_the_lists() {
999 let f = fixture().await;
1000 open(&f, "steid", "Blame is slow").await;
1001
1002 set_state(&f, 1, IssueState::Closed).await.expect("close");
1003 let closed = detail(&f, &f.owner, "steid", 1).await.expect("visible");
1004 assert_eq!(closed.state, IssueState::Closed);
1005 assert_eq!(closed.closed_at, Some(at(3_000)));
1006
1007 set_state(&f, 1, IssueState::Open).await.expect("reopen");
1008 let reopened = detail(&f, &f.owner, "steid", 1).await.expect("visible");
1009 assert_eq!(reopened.state, IssueState::Open);
1010 assert_eq!(reopened.closed_at, None);
1011 }
1012
1013 #[tokio::test]
1014 async fn editing_replaces_the_title_and_body() {
1015 let f = fixture().await;
1016 open(&f, "steid", "Blame is slow").await;
1017
1018 edit_issue(
1019 &f.owner,
1020 &f.handle,
1021 &repo("steid"),
1022 IssueNumber::from_trusted(1),
1023 &IssueEdit {
1024 title: "Blame is very slow".to_owned(),
1025 body: "with numbers".to_owned(),
1026 },
1027 at(5_000),
1028 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1029 &f.issues,
1030 )
1031 .await
1032 .expect("should edit");
1033
1034 let issue = detail(&f, &f.owner, "steid", 1).await.expect("visible");
1035
1036 assert_eq!(issue.title, "Blame is very slow");
1037 assert_eq!(issue.body, "with numbers");
1038 }
1039
1040 #[tokio::test]
1041 async fn an_issue_needs_a_title_and_a_comment_needs_a_body() {
1042 let f = fixture().await;
1043
1044 let error = create_issue(
1045 &f.owner,
1046 &f.handle,
1047 &repo("steid"),
1048 &NewIssue {
1049 title: " ".to_owned(),
1050 body: "body".to_owned(),
1051 },
1052 at(2_000),
1053 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1054 &f.issues,
1055 )
1056 .await
1057 .expect_err("should refuse");
1058
1059 assert!(matches!(
1060 error,
1061 Error::Domain(DomainError::Validation { .. })
1062 ));
1063 assert!(
1064 listed(&f, &f.owner, "steid", IssueState::Open)
1065 .await
1066 .expect("visible")
1067 .issues
1068 .items
1069 .is_empty()
1070 );
1071
1072 open(&f, "steid", "Blame is slow").await;
1073 assert!(comment(&f, &f.owner, 1, " ").await.is_err());
1074 }
1075
1076 #[tokio::test]
1077 async fn a_write_aimed_at_a_number_that_is_not_there_is_not_found() {
1078 let f = fixture().await;
1079
1080 assert!(is_not_found(
1081 &set_state(&f, 9, IssueState::Closed)
1082 .await
1083 .expect_err("refuse")
1084 ));
1085 assert!(is_not_found(
1086 &comment(&f, &f.owner, 9, "hello").await.expect_err("refuse")
1087 ));
1088 }
1089
1090 // --- authorization --------------------------------------------------------------
1091
1092 #[tokio::test]
1093 async fn only_the_owner_may_open_an_issue() {
1094 // A member's read access is not permission to add to someone's tracker.
1095 let f = fixture().await;
1096
1097 for actor in [&f.member, &f.stranger] {
1098 let error = create_issue(
1099 actor,
1100 &f.handle,
1101 &repo("steid"),
1102 &NewIssue {
1103 title: "not mine".to_owned(),
1104 body: String::new(),
1105 },
1106 at(2_000),
1107 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1108 &f.issues,
1109 )
1110 .await
1111 .expect_err("should refuse");
1112
1113 assert!(is_forbidden(&error), "{actor:?} should be forbidden");
1114 }
1115
1116 assert!(
1117 listed(&f, &f.owner, "steid", IssueState::Open)
1118 .await
1119 .expect("visible")
1120 .issues
1121 .items
1122 .is_empty()
1123 );
1124 }
1125
1126 #[tokio::test]
1127 async fn only_the_owner_may_comment_close_or_edit() {
1128 let f = fixture().await;
1129 open(&f, "steid", "Blame is slow").await;
1130
1131 for actor in [&f.member, &f.stranger] {
1132 assert!(is_forbidden(
1133 &comment(&f, actor, 1, "hello").await.expect_err("refuse")
1134 ));
1135
1136 let error = set_issue_state(
1137 actor,
1138 &f.handle,
1139 &repo("steid"),
1140 IssueNumber::from_trusted(1),
1141 IssueState::Closed,
1142 at(3_000),
1143 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1144 &f.issues,
1145 )
1146 .await
1147 .expect_err("should refuse");
1148 assert!(is_forbidden(&error));
1149
1150 let error = edit_issue(
1151 actor,
1152 &f.handle,
1153 &repo("steid"),
1154 IssueNumber::from_trusted(1),
1155 &IssueEdit {
1156 title: "hijacked".to_owned(),
1157 body: String::new(),
1158 },
1159 at(3_000),
1160 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1161 &f.issues,
1162 )
1163 .await
1164 .expect_err("should refuse");
1165 assert!(is_forbidden(&error));
1166 }
1167
1168 let issue = detail(&f, &f.owner, "steid", 1).await.expect("visible");
1169 assert_eq!(issue.title, "Blame is slow");
1170 assert_eq!(issue.state, IssueState::Open);
1171 assert!(issue.comments.is_empty());
1172 }
1173
1174 #[tokio::test]
1175 async fn a_write_on_an_invisible_repository_is_not_found_rather_than_forbidden() {
1176 // Forbidden would confirm that a private repository by that name exists.
1177 let f = fixture().await;
1178
1179 let error = create_issue(
1180 &f.stranger,
1181 &f.handle,
1182 &repo("secret"),
1183 &NewIssue {
1184 title: "prying".to_owned(),
1185 body: String::new(),
1186 },
1187 at(2_000),
1188 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1189 &f.issues,
1190 )
1191 .await
1192 .expect_err("should refuse");
1193
1194 assert!(is_not_found(&error));
1195 }
1196
1197 #[tokio::test]
1198 async fn a_repository_that_does_not_exist_answers_the_same_way_throughout() {
1199 let f = fixture().await;
1200
1201 assert!(
1202 listed(&f, &f.owner, "nothing", IssueState::Open)
1203 .await
1204 .is_none()
1205 );
1206 assert!(detail(&f, &f.owner, "nothing", 1).await.is_none());
1207
1208 let error = create_issue(
1209 &f.owner,
1210 &f.handle,
1211 &repo("nothing"),
1212 &NewIssue {
1213 title: "nowhere".to_owned(),
1214 body: String::new(),
1215 },
1216 at(2_000),
1217 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1218 &f.issues,
1219 )
1220 .await
1221 .expect_err("should refuse");
1222
1223 assert!(is_not_found(&error));
1224 }
1225}