gitforge/models/repo/watch_test.go
christopher-besch 8770ffc848 feat: granular settings for watched repo units (#10927)
Continuation of #10598
Closes #7254

Implement repo granular watch selection

I adopted a lot of the frontend from #10598 while redoing the entire back-end. I propose this new model:
- Remove the WatchMode enum.
- Add the WatchSelection struct, which represents the granular selection. Notice that there is no `watching` bool. I tried very hard to keep the structure as simple and redundancy-free as possible. Therefore, people not watching a repo at all either don't have a watch record or one with an entirely unselected WatchSelection struct.
- Add the WatchSource enum. It replaces the WatchMode enum and has a single purpose: determine whether a watch was explicitly or automatically initiated.

Notice that replacing this
```go
		And("`watch`.mode<>?", WatchModeDont).
```
with this is correct:
```go
		And(
			builder.Or(
				builder.Eq{"`watch`.watch_selection_issues": true},
				builder.Eq{"`watch`.watch_selection_pull_requests": true},
				builder.Eq{"`watch`.watch_selection_releases": true},
			),
		).
```
That's because there are four modes: dont, none, auto and normal. When `<>` with dont, we look for auto and normal, because there are no records with none. Therefore, the old code looks for records that indicate watching. The code I replaced this with does so, too, just more granular.

Also notice that I've prepared a future `user preset` in a few places. See below for a little more info on that.

## Next PR
I plan to continue working on this. I want to implement a `user preset` option. The user sets that `user preset` in her settings and may use them in any repo.
<details>
- rename account settings to account and notifications
- user preset (always use this preset for newly accessible repos (according to AutoWatchOnChanges and AutoWatchNewRepos))
</details>

## Further PRs
- make api able to granular watch
- move (email) notifications to new notifications tab

Co-authored-by: 0ko <0ko@noreply.codeberg.org>
Co-authored-by: Gusted <postmaster@gusted.xyz>
Co-authored-by: pat-s <patrick.schratz@gmail.com>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10927
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
2026-07-09 19:51:10 +02:00

338 lines
12 KiB
Go

// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo_test
import (
"testing"
"forgejo.org/models/db"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest"
"forgejo.org/modules/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIsWatching(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
assert.True(t, repo_model.IsWatcher(db.DefaultContext, 1, 1))
assert.True(t, repo_model.IsWatcher(db.DefaultContext, 4, 1))
assert.True(t, repo_model.IsWatcher(db.DefaultContext, 11, 1))
assert.False(t, repo_model.IsWatcher(db.DefaultContext, 1, 5))
assert.False(t, repo_model.IsWatcher(db.DefaultContext, 8, 1))
assert.True(t, repo_model.IsWatcher(db.DefaultContext, 4, 3))
assert.True(t, repo_model.IsWatcher(db.DefaultContext, 4, 4))
assert.True(t, repo_model.IsWatcher(db.DefaultContext, 4, 5))
assert.True(t, repo_model.IsWatcher(db.DefaultContext, 5, 5))
watch, err := repo_model.GetWatch(db.DefaultContext, 5, 5)
require.NoError(t, err)
assert.Equal(t, repo_model.WatchSourceExplicit, watch.Source)
assert.False(t, watch.WatchSelectionIssues)
assert.True(t, watch.WatchSelectionPullRequests)
assert.True(t, watch.WatchSelectionReleases)
assert.False(t, repo_model.IsWatcher(db.DefaultContext, unittest.NonexistentID, unittest.NonexistentID))
}
func TestGetSelectWatchers(t *testing.T) {
for idx, test := range []struct {
RepoID int64
Selection repo_model.WatchSelection
ExpectedUserIDs []int64
}{
{
1,
repo_model.WatchNoneSelection,
// This should always be empty for any repo.
[]int64{},
},
{
3,
repo_model.WatchNoneSelection,
// This should always be empty for any repo.
[]int64{},
},
{
3,
repo_model.WatchAllSelection,
[]int64{4},
},
{
3,
repo_model.WatchSelection{Issues: true},
[]int64{4},
},
{
3,
repo_model.WatchSelection{PullRequests: true},
[]int64{},
},
{
3,
repo_model.WatchSelection{Releases: true},
[]int64{},
},
{
3,
repo_model.WatchSelection{Releases: true, PullRequests: true},
[]int64{},
},
{
5,
repo_model.WatchSelection{Releases: true},
[]int64{4, 5},
},
{
5,
repo_model.WatchSelection{Releases: true, PullRequests: true},
[]int64{4, 5},
},
{
5,
repo_model.WatchSelection{PullRequests: true},
[]int64{5},
},
} {
watchers, err := repo_model.GetSelectWatchers(db.DefaultContext, test.RepoID, test.Selection)
require.NoError(t, err)
if assert.Len(t, watchers, len(test.ExpectedUserIDs), "idx: %d", idx) {
for i, watcher := range watchers {
assert.Equal(t, test.ExpectedUserIDs[i], watcher.UserID)
}
}
watcherIDs, err := repo_model.GetSelectWatcherIDs(db.DefaultContext, test.RepoID, test.Selection)
require.NoError(t, err)
assert.Equal(t, test.ExpectedUserIDs, watcherIDs, "idx: %d", idx)
}
}
func TestRepository_GetWatchers(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
watchers, err := repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
assert.Len(t, watchers, repo.NumWatches)
for _, watcher := range watchers {
unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: watcher.ID, RepoID: repo.ID})
}
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 9})
watchers, err = repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
assert.Empty(t, watchers)
}
func TestWatchRepoExplicitly(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// There is no record for this watch in the fixture.
assert.False(t, repo_model.IsWatcher(db.DefaultContext, 1, 2))
{
watch, err := repo_model.GetWatch(db.DefaultContext, 1, 2)
require.NoError(t, err)
assert.Equal(t, repo_model.WatchSourceAutomatic, watch.Source)
assert.False(t, watch.WatchSelectionIssues)
assert.False(t, watch.WatchSelectionPullRequests)
assert.False(t, watch.WatchSelectionReleases)
}
require.NoError(t, repo_model.WatchRepoExplicitly(db.DefaultContext, 1, 2, repo_model.WatchAllSelection))
assert.True(t, repo_model.IsWatcher(db.DefaultContext, 1, 2))
{
watch, err := repo_model.GetWatch(db.DefaultContext, 1, 2)
require.NoError(t, err)
assert.Equal(t, repo_model.WatchSourceExplicit, watch.Source)
assert.True(t, watch.WatchSelectionIssues)
assert.True(t, watch.WatchSelectionPullRequests)
assert.True(t, watch.WatchSelectionReleases)
}
require.NoError(t, repo_model.WatchRepoExplicitly(db.DefaultContext, 1, 2, repo_model.WatchNoneSelection))
assert.False(t, repo_model.IsWatcher(db.DefaultContext, 1, 2))
{
watch, err := repo_model.GetWatch(db.DefaultContext, 1, 2)
require.NoError(t, err)
assert.Equal(t, repo_model.WatchSourceExplicit, watch.Source)
assert.False(t, watch.WatchSelectionIssues)
assert.False(t, watch.WatchSelectionPullRequests)
assert.False(t, watch.WatchSelectionReleases)
}
require.NoError(t, repo_model.WatchRepoExplicitly(db.DefaultContext, 1, 2, repo_model.WatchSelection{Issues: true}))
assert.True(t, repo_model.IsWatcher(db.DefaultContext, 1, 2))
{
watch, err := repo_model.GetWatch(db.DefaultContext, 1, 2)
require.NoError(t, err)
assert.Equal(t, repo_model.WatchSourceExplicit, watch.Source)
assert.True(t, watch.WatchSelectionIssues)
assert.False(t, watch.WatchSelectionPullRequests)
assert.False(t, watch.WatchSelectionReleases)
}
require.NoError(t, repo_model.WatchRepoExplicitly(db.DefaultContext, 1, 2, repo_model.WatchSelection{Issues: true, PullRequests: true}))
assert.True(t, repo_model.IsWatcher(db.DefaultContext, 1, 2))
{
watch, err := repo_model.GetWatch(db.DefaultContext, 1, 2)
require.NoError(t, err)
assert.Equal(t, repo_model.WatchSourceExplicit, watch.Source)
assert.True(t, watch.WatchSelectionIssues)
assert.True(t, watch.WatchSelectionPullRequests)
assert.False(t, watch.WatchSelectionReleases)
}
require.NoError(t, repo_model.WatchRepoExplicitly(db.DefaultContext, 1, 2, repo_model.WatchSelection{Releases: true}))
assert.True(t, repo_model.IsWatcher(db.DefaultContext, 1, 2))
{
watch, err := repo_model.GetWatch(db.DefaultContext, 1, 2)
require.NoError(t, err)
assert.Equal(t, repo_model.WatchSourceExplicit, watch.Source)
assert.False(t, watch.WatchSelectionIssues)
assert.False(t, watch.WatchSelectionPullRequests)
assert.True(t, watch.WatchSelectionReleases)
}
}
func TestWatchIfAutoWatchNewRepos(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
watchers, err := repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
assert.Len(t, watchers, repo.NumWatches)
setting.Service.AutoWatchNewRepos = false
prevCount := repo.NumWatches
// Must not add watch
require.NoError(t, repo_model.WatchIfAutoWatchNewRepos(db.DefaultContext, 8, 1))
watchers, err = repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
assert.Len(t, watchers, prevCount)
// Should not add watch
require.NoError(t, repo_model.WatchIfAutoWatchNewRepos(db.DefaultContext, 10, 1))
watchers, err = repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
assert.Len(t, watchers, prevCount)
setting.Service.AutoWatchNewRepos = true
// Must not add watch
require.NoError(t, repo_model.WatchIfAutoWatchNewRepos(db.DefaultContext, 8, 1))
watchers, err = repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
assert.Len(t, watchers, prevCount)
// Should not add watch
// We simply don't WatchIfAuto
watchers, err = repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
assert.Len(t, watchers, prevCount)
// Should add watch
require.NoError(t, repo_model.WatchIfAutoWatchNewRepos(db.DefaultContext, 12, 1))
watchers, err = repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
assert.Len(t, watchers, prevCount+1)
watch, err := repo_model.GetWatch(db.DefaultContext, 12, 1)
require.NoError(t, err)
require.Equal(t, repo_model.WatchSourceAutomatic, watch.Source)
require.True(t, watch.WatchSelectionIssues)
require.True(t, watch.WatchSelectionPullRequests)
require.True(t, watch.WatchSelectionReleases)
// Should remove watch, inhibit from adding auto
require.NoError(t, repo_model.WatchRepoExplicitly(db.DefaultContext, 12, 1, repo_model.WatchNoneSelection))
watchers, err = repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
assert.Len(t, watchers, prevCount)
// Must not add watch
require.NoError(t, repo_model.WatchIfAutoWatchNewRepos(db.DefaultContext, 12, 1))
watchers, err = repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
assert.Len(t, watchers, prevCount)
}
func TestWatchIfAutoWatchOnChanges(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
watchers, err := repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
assert.Len(t, watchers, repo.NumWatches)
setting.Service.AutoWatchOnChanges = false
prevCount := repo.NumWatches
// Must not add watch
require.NoError(t, repo_model.WatchIfAutoWatchOnChanges(db.DefaultContext, 8, 1))
watchers, err = repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
assert.Len(t, watchers, prevCount)
// Should not add watch
require.NoError(t, repo_model.WatchIfAutoWatchOnChanges(db.DefaultContext, 10, 1))
watchers, err = repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
assert.Len(t, watchers, prevCount)
setting.Service.AutoWatchOnChanges = true
// Must not add watch
require.NoError(t, repo_model.WatchIfAutoWatchOnChanges(db.DefaultContext, 8, 1))
watchers, err = repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
assert.Len(t, watchers, prevCount)
// Should not add watch
// We simply don't WatchIfAuto
watchers, err = repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
assert.Len(t, watchers, prevCount)
// Should add watch
require.NoError(t, repo_model.WatchIfAutoWatchOnChanges(db.DefaultContext, 12, 1))
watchers, err = repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
assert.Len(t, watchers, prevCount+1)
watch, err := repo_model.GetWatch(db.DefaultContext, 12, 1)
require.NoError(t, err)
require.Equal(t, repo_model.WatchSourceAutomatic, watch.Source)
require.True(t, watch.WatchSelectionIssues)
require.True(t, watch.WatchSelectionPullRequests)
require.True(t, watch.WatchSelectionReleases)
// Should remove watch, inhibit from adding auto
require.NoError(t, repo_model.WatchRepoExplicitly(db.DefaultContext, 12, 1, repo_model.WatchNoneSelection))
watchers, err = repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
assert.Len(t, watchers, prevCount)
// Must not add watch
require.NoError(t, repo_model.WatchIfAutoWatchOnChanges(db.DefaultContext, 12, 1))
watchers, err = repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
assert.Len(t, watchers, prevCount)
}
func TestUnwatchRepos(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: 4, RepoID: 1})
unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: 4, RepoID: 2})
err := repo_model.UnwatchRepos(db.DefaultContext, 4, []int64{1, 2})
require.NoError(t, err)
assert.Equal(t, 1, unittest.GetCount(t, &repo_model.Watch{UserID: 4, RepoID: 1}, "source = false AND watch_selection_issues = false AND watch_selection_pull_requests = false AND watch_selection_releases = false"))
assert.Equal(t, 1, unittest.GetCount(t, &repo_model.Watch{UserID: 4, RepoID: 2}, "source = false AND watch_selection_issues = false AND watch_selection_pull_requests = false AND watch_selection_releases = false"))
}