mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(coderd/gitsync): consolidate chat diff refresh paths through Worker.RefreshChat (#22938)
## Problem
Two separate code paths refreshed chat diff statuses:
1. **HTTP handler** (`refreshChatDiffStatus`): resolved
provider/token/status inline, ran under the user's context. Worked fine
because the user owns their external auth links.
2. **Background worker** (`Refresher.Refresh`): ran under `AsChatd`
context, which lacked `ActionReadPersonal` on `ResourceUser`.
`GetExternalAuthLink` failed silently (`if err != nil { continue }`),
returning `ErrNoTokenAvailable` every time. Chat diff statuses got
`git_branch`/`git_remote_origin` from `MarkStale` but `refreshed_at`,
`url`, `pull_request_state` stayed nil.
Having two paths also meant bug fixes had to be applied twice.
## Fix
- **`Worker.RefreshChat`**: New method for synchronous, on-demand
refresh of a single chat. Uses the same `Refresher.Refresh` pipeline as
the background `tick()`. Called by the HTTP handler for instant
response.
- **`resolveChatGitAccessToken`**: Uses
`dbauthz.AsSystemRestricted(ctx)` specifically for `GetExternalAuthLink`
and `RefreshToken` calls. This is scoped to just those DB operations
rather than broadening the chatd RBAC role.
- **Removed**: `refreshChatDiffStatus`, `shouldRefreshChatDiffStatus`,
`resolveChatDiffStatusWithOptions` (all replaced by the single
`RefreshChat` path).
## Tests
Added 4 tests for `Worker.RefreshChat`:
- `TestRefreshChat_Success`: full refresh + upsert + publish
- `TestRefreshChat_NoPR`: no PR exists yet, nil result
- `TestRefreshChat_RefreshError`: provider resolution fails
- `TestRefreshChat_UpsertError`: refresh succeeds but DB write fails
## Why tests didn't catch the original bug
- Worker tests used mock stores (no dbauthz) and fake token resolvers
(hardcoded lambdas)
- No integration test exercised `AsChatd` -> `resolveChatGitAccessToken`
-> `GetExternalAuthLink` through dbauthz
This commit is contained in:
+20
-94
@@ -1138,15 +1138,6 @@ func chatWorkspaceAuditStatus(err error) int {
|
||||
func (api *API) resolveChatDiffStatus(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
) (*database.ChatDiffStatus, error) {
|
||||
return api.resolveChatDiffStatusWithOptions(ctx, chat, false)
|
||||
}
|
||||
|
||||
//nolint:revive // Boolean forces cache refresh bypass.
|
||||
func (api *API) resolveChatDiffStatusWithOptions(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
forceRefresh bool,
|
||||
) (*database.ChatDiffStatus, error) {
|
||||
status, found, err := api.getCachedChatDiffStatus(ctx, chat.ID)
|
||||
if err != nil {
|
||||
@@ -1172,26 +1163,25 @@ func (api *API) resolveChatDiffStatusWithOptions(
|
||||
if !found {
|
||||
return nil, nil //nolint:nilnil // Callers handle nil status explicitly.
|
||||
}
|
||||
if reference.PullRequestURL == "" {
|
||||
return &status, nil
|
||||
}
|
||||
if !shouldRefreshChatDiffStatus(status, now, forceRefresh) {
|
||||
if !chatDiffStatusIsStale(status, now) {
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
refreshed, err := api.refreshChatDiffStatus(
|
||||
ctx,
|
||||
chat.OwnerID,
|
||||
chat.ID,
|
||||
reference.PullRequestURL,
|
||||
// Use the same refresh pipeline as the background worker
|
||||
// so both paths share identical provider/token resolution.
|
||||
refreshed, err := api.gitSyncWorker.RefreshChat(
|
||||
ctx, status, chat.OwnerID,
|
||||
)
|
||||
if err == nil && refreshed != nil {
|
||||
return refreshed, nil
|
||||
}
|
||||
if err == nil {
|
||||
return &refreshed, nil
|
||||
// No PR exists yet; return what we have.
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
api.Logger.Warn(ctx, "failed to refresh chat diff status",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("pull_request_url", reference.PullRequestURL),
|
||||
slog.Error(err),
|
||||
)
|
||||
|
||||
@@ -1207,14 +1197,6 @@ func (api *API) resolveChatDiffStatusWithOptions(
|
||||
return &backoffStatus, nil
|
||||
}
|
||||
|
||||
//nolint:revive // Boolean forces cache refresh bypass.
|
||||
func shouldRefreshChatDiffStatus(status database.ChatDiffStatus, now time.Time, forceRefresh bool) bool {
|
||||
if forceRefresh {
|
||||
return true
|
||||
}
|
||||
return chatDiffStatusIsStale(status, now)
|
||||
}
|
||||
|
||||
func (api *API) resolveChatDiffContents(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
@@ -1488,68 +1470,6 @@ func chatDiffStatusIsStale(status database.ChatDiffStatus, now time.Time) bool {
|
||||
return !status.StaleAt.After(now)
|
||||
}
|
||||
|
||||
func (api *API) refreshChatDiffStatus(
|
||||
ctx context.Context,
|
||||
chatOwnerID uuid.UUID,
|
||||
chatID uuid.UUID,
|
||||
pullRequestURL string,
|
||||
) (database.ChatDiffStatus, error) {
|
||||
// Find a provider that can handle this PR URL.
|
||||
var gp gitprovider.Provider
|
||||
var ref gitprovider.PRRef
|
||||
for _, extAuth := range api.ExternalAuthConfigs {
|
||||
p := extAuth.Git(api.HTTPClient)
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
if parsed, ok := p.ParsePullRequestURL(pullRequestURL); ok {
|
||||
gp = p
|
||||
ref = parsed
|
||||
break
|
||||
}
|
||||
}
|
||||
if gp == nil {
|
||||
return database.ChatDiffStatus{}, xerrors.Errorf("no git provider found for PR URL %q", pullRequestURL)
|
||||
}
|
||||
|
||||
origin := gp.BuildRepositoryURL(ref.Owner, ref.Repo)
|
||||
token, err := api.resolveChatGitAccessToken(ctx, chatOwnerID, origin)
|
||||
if err != nil {
|
||||
return database.ChatDiffStatus{}, xerrors.Errorf("resolve git access token: %w", err)
|
||||
} else if token == nil {
|
||||
return database.ChatDiffStatus{}, xerrors.New("nil git access token")
|
||||
}
|
||||
status, err := gp.FetchPullRequestStatus(ctx, *token, ref)
|
||||
if err != nil {
|
||||
return database.ChatDiffStatus{}, err
|
||||
}
|
||||
|
||||
refreshedAt := time.Now().UTC()
|
||||
refreshedStatus, err := api.Database.UpsertChatDiffStatus(
|
||||
ctx,
|
||||
database.UpsertChatDiffStatusParams{
|
||||
ChatID: chatID,
|
||||
Url: sql.NullString{String: pullRequestURL, Valid: true},
|
||||
PullRequestState: sql.NullString{
|
||||
String: string(status.State),
|
||||
Valid: status.State != "",
|
||||
},
|
||||
PullRequestTitle: status.Title,
|
||||
PullRequestDraft: status.Draft,
|
||||
ChangesRequested: status.ChangesRequested,
|
||||
Additions: status.DiffStats.Additions,
|
||||
Deletions: status.DiffStats.Deletions,
|
||||
ChangedFiles: status.DiffStats.ChangedFiles,
|
||||
RefreshedAt: refreshedAt,
|
||||
StaleAt: refreshedAt.Add(chatDiffStatusTTL),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return database.ChatDiffStatus{}, xerrors.Errorf("upsert chat diff status: %w", err)
|
||||
}
|
||||
return refreshedStatus, nil
|
||||
}
|
||||
|
||||
func (api *API) resolveChatGitAccessToken(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
@@ -1565,7 +1485,9 @@ func (api *API) resolveChatGitAccessToken(
|
||||
if config.Regex == nil || !config.Regex.MatchString(origin) {
|
||||
continue
|
||||
}
|
||||
link, err := api.Database.GetExternalAuthLink(ctx,
|
||||
//nolint:gocritic // System access needed to read external auth
|
||||
// links when called from the gitsync worker (chatd context).
|
||||
link, err := api.Database.GetExternalAuthLink(dbauthz.AsSystemRestricted(ctx),
|
||||
database.GetExternalAuthLinkParams{
|
||||
ProviderID: config.ID,
|
||||
UserID: userID,
|
||||
@@ -1574,7 +1496,8 @@ func (api *API) resolveChatGitAccessToken(
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
refreshed, refreshErr := config.RefreshToken(ctx, api.Database, link)
|
||||
//nolint:gocritic // System context carried through for token refresh.
|
||||
refreshed, refreshErr := config.RefreshToken(dbauthz.AsSystemRestricted(ctx), api.Database, link)
|
||||
if refreshErr == nil {
|
||||
link = refreshed
|
||||
}
|
||||
@@ -1602,8 +1525,10 @@ func (api *API) resolveChatGitAccessToken(
|
||||
}
|
||||
seen[providerID] = struct{}{}
|
||||
|
||||
//nolint:gocritic // System access needed to read external auth
|
||||
// links when called from the gitsync worker (chatd context).
|
||||
link, err := api.Database.GetExternalAuthLink(
|
||||
ctx,
|
||||
dbauthz.AsSystemRestricted(ctx),
|
||||
database.GetExternalAuthLinkParams{
|
||||
ProviderID: providerID,
|
||||
UserID: userID,
|
||||
@@ -1617,7 +1542,8 @@ func (api *API) resolveChatGitAccessToken(
|
||||
// the same code path used by provisionerdserver when handing
|
||||
// tokens to provisioners.
|
||||
if cfg, ok := configs[providerID]; ok {
|
||||
refreshed, refreshErr := cfg.RefreshToken(ctx, api.Database, link)
|
||||
//nolint:gocritic // System context carried through for token refresh.
|
||||
refreshed, refreshErr := cfg.RefreshToken(dbauthz.AsSystemRestricted(ctx), api.Database, link)
|
||||
if refreshErr != nil {
|
||||
api.Logger.Debug(ctx, "failed to refresh external auth token for chat diff",
|
||||
slog.F("provider_id", providerID),
|
||||
|
||||
+128
-3
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
@@ -2565,9 +2567,8 @@ func TestGetChatDiffStatus(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
refreshedAt := time.Date(2026, time.January, 15, 12, 0, 0, 0, time.UTC)
|
||||
staleAt := time.Date(2026, time.January, 15, 13, 0, 0, 0, time.UTC)
|
||||
|
||||
refreshedAt := time.Now().UTC().Truncate(time.Second)
|
||||
staleAt := refreshedAt.Add(time.Hour)
|
||||
_, err = db.UpsertChatDiffStatusReference(
|
||||
dbauthz.AsSystemRestricted(ctx),
|
||||
database.UpsertChatDiffStatusReferenceParams{
|
||||
@@ -2638,6 +2639,130 @@ func TestGetChatDiffStatus(t *testing.T) {
|
||||
_, err = otherClient.GetChatDiffStatus(ctx, createdChat.ID)
|
||||
requireSDKError(t, err, http.StatusNotFound)
|
||||
})
|
||||
|
||||
// Integration test: exercises the full HTTP handler refresh
|
||||
// path with a real DB, dbauthz, a mock GitHub API, and an
|
||||
// external-auth-linked user. Verifies that a stale chat diff
|
||||
// status is refreshed end-to-end via the gitsync worker's
|
||||
// Refresh pipeline (provider resolution, token acquisition
|
||||
// through external auth, and PR status fetch).
|
||||
t.Run("RefreshesStaleStatusWithExternalAuth", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Mock GitHub API over TLS so the git provider's URL patterns
|
||||
// (which require https://) match our PR URLs.
|
||||
ghAPI := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
// PR status: GET /repos/{owner}/{repo}/pulls/{number}
|
||||
case r.URL.Path == "/repos/testorg/testrepo/pulls/42" && r.URL.Query().Get("per_page") == "":
|
||||
_, _ = w.Write([]byte(`{
|
||||
"state": "open",
|
||||
"merged": false,
|
||||
"draft": false,
|
||||
"additions": 25,
|
||||
"deletions": 7,
|
||||
"changed_files": 4,
|
||||
"head": {"sha": "abc123"}
|
||||
}`))
|
||||
// PR reviews: GET /repos/{owner}/{repo}/pulls/{number}/reviews
|
||||
case strings.HasSuffix(r.URL.Path, "/reviews"):
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(ghAPI.Close)
|
||||
|
||||
// The git provider derives webBaseURL from apiBaseURL.
|
||||
// For a TLS server at https://127.0.0.1:PORT, webBaseURL
|
||||
// is the same, and PR URL patterns match
|
||||
// https://127.0.0.1:PORT/{owner}/{repo}/pull/{number}.
|
||||
ghWebHost := strings.TrimPrefix(ghAPI.URL, "https://")
|
||||
prURL := fmt.Sprintf("https://%s/testorg/testrepo/pull/42", ghWebHost)
|
||||
remoteOrigin := fmt.Sprintf("https://%s/testorg/testrepo.git", ghWebHost)
|
||||
|
||||
// Set up a fake OIDC IDP for external auth login.
|
||||
const providerID = "test-github"
|
||||
fake := oidctest.NewFakeIDP(t, oidctest.WithServing())
|
||||
|
||||
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
DeploymentValues: chatDeploymentValues(t),
|
||||
ExternalAuthConfigs: []*externalauth.Config{
|
||||
fake.ExternalAuthConfig(t, providerID, nil, func(cfg *externalauth.Config) {
|
||||
cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String()
|
||||
// Point the git provider at our mock API server.
|
||||
cfg.APIBaseURL = ghAPI.URL
|
||||
// Match the remote origin (127.0.0.1 host).
|
||||
cfg.Regex = regexp.MustCompile(regexp.QuoteMeta(ghWebHost))
|
||||
}),
|
||||
},
|
||||
})
|
||||
db := api.Database
|
||||
|
||||
// Use the TLS mock server's HTTP client (which trusts its
|
||||
// self-signed cert) for git provider API calls.
|
||||
api.HTTPClient = ghAPI.Client()
|
||||
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
// Log in to the external auth provider so the user has an
|
||||
// ExternalAuthLink row in the DB. This is what
|
||||
// resolveChatGitAccessToken reads via GetExternalAuthLink.
|
||||
fake.ExternalLogin(t, client)
|
||||
|
||||
// Insert a chat owned by the user.
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "rbac integration test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store a pre-resolved PR URL so the refresh path uses
|
||||
// ParsePullRequestURL directly (skipping branch-to-PR
|
||||
// resolution, which isn't what we're testing). The status
|
||||
// is stale (stale_at in the past) so the handler triggers
|
||||
// a full refresh through RefreshChat.
|
||||
_, err = db.UpsertChatDiffStatusReference(
|
||||
dbauthz.AsSystemRestricted(ctx),
|
||||
database.UpsertChatDiffStatusReferenceParams{
|
||||
ChatID: chat.ID,
|
||||
Url: sql.NullString{String: prURL, Valid: true},
|
||||
GitBranch: "feature/rbac-fix",
|
||||
GitRemoteOrigin: remoteOrigin,
|
||||
StaleAt: time.Now().Add(-time.Minute),
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Call the HTTP endpoint. This exercises the full code
|
||||
// path: resolveChatDiffStatus -> RefreshChat (with
|
||||
// AsSystemRestricted) -> Refresher.Refresh ->
|
||||
// resolveChatGitAccessToken (GetExternalAuthLink with
|
||||
// AsSystemRestricted) -> FetchPullRequestStatus (mock).
|
||||
//
|
||||
// Without the AsSystemRestricted fix, GetExternalAuthLink
|
||||
// would fail under the chatd RBAC context (missing
|
||||
// ActionReadPersonal), causing ErrNoTokenAvailable and a
|
||||
// refresh failure that silently returns stale data.
|
||||
status, err := client.GetChatDiffStatus(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The mock GitHub API returned PR #42 with 25 additions,
|
||||
// 7 deletions, 4 changed files, state "open".
|
||||
require.NotNil(t, status.RefreshedAt, "status should have been refreshed")
|
||||
require.NotNil(t, status.PullRequestState)
|
||||
require.Equal(t, "open", *status.PullRequestState)
|
||||
require.EqualValues(t, 25, status.Additions)
|
||||
require.EqualValues(t, 7, status.Deletions)
|
||||
require.EqualValues(t, 4, status.ChangedFiles)
|
||||
require.NotNil(t, status.URL)
|
||||
require.Contains(t, *status.URL, "pull/42")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetChatDiffContents(t *testing.T) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
@@ -238,6 +239,52 @@ func (w *Worker) MarkStale(
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshChat synchronously refreshes a single chat's diff
|
||||
// status using the same Refresher pipeline as the background
|
||||
// worker. Returns nil, nil when no PR exists yet for the
|
||||
// branch. Called from HTTP handlers for instant feedback.
|
||||
func (w *Worker) RefreshChat(
|
||||
ctx context.Context,
|
||||
row database.ChatDiffStatus,
|
||||
ownerID uuid.UUID,
|
||||
) (*database.ChatDiffStatus, error) {
|
||||
requests := []RefreshRequest{{
|
||||
Row: row,
|
||||
OwnerID: ownerID,
|
||||
}}
|
||||
|
||||
results, err := w.refresher.Refresh(ctx, requests)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("refresh chat diff status: %w", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
res := results[0]
|
||||
if res.Error != nil {
|
||||
return nil, xerrors.Errorf("refresh chat diff status: %w", res.Error)
|
||||
}
|
||||
if res.Params == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
upserted, err := w.store.UpsertChatDiffStatus(ctx, *res.Params)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("upsert chat diff status: %w", err)
|
||||
}
|
||||
|
||||
if w.publishDiffStatusChangeFn != nil {
|
||||
if err := w.publishDiffStatusChangeFn(ctx, row.ChatID); err != nil {
|
||||
w.logger.Debug(ctx, "publish diff status change",
|
||||
slog.F("chat_id", row.ChatID),
|
||||
slog.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
return &upserted, nil
|
||||
}
|
||||
|
||||
// filterChatsByWorkspaceID returns only chats associated with
|
||||
// the given workspace.
|
||||
func filterChatsByWorkspaceID(
|
||||
|
||||
@@ -742,3 +742,164 @@ func TestWorker(t *testing.T) {
|
||||
expectedStaleAt := mClock.Now().Add(gitsync.DiffStatusTTL)
|
||||
assert.WithinDuration(t, expectedStaleAt, status.StaleAt, time.Second)
|
||||
}
|
||||
|
||||
func TestRefreshChat_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
chatID := uuid.New()
|
||||
ownerID := uuid.New()
|
||||
|
||||
row := database.ChatDiffStatus{
|
||||
ChatID: chatID,
|
||||
GitBranch: "feature",
|
||||
GitRemoteOrigin: "https://github.com/owner/repo",
|
||||
}
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
store := dbmock.NewMockStore(ctrl)
|
||||
|
||||
upsertedStatus := database.ChatDiffStatus{
|
||||
ChatID: chatID,
|
||||
Url: sql.NullString{String: "https://github.com/o/r/pull/1", Valid: true},
|
||||
Additions: 10,
|
||||
Deletions: 3,
|
||||
ChangedFiles: 2,
|
||||
}
|
||||
store.EXPECT().UpsertChatDiffStatus(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, arg database.UpsertChatDiffStatusParams) (database.ChatDiffStatus, error) {
|
||||
assert.Equal(t, chatID, arg.ChatID)
|
||||
return upsertedStatus, nil
|
||||
})
|
||||
|
||||
var publishCalled atomic.Bool
|
||||
pub := func(_ context.Context, id uuid.UUID) error {
|
||||
assert.Equal(t, chatID, id)
|
||||
publishCalled.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
mClock := quartz.NewMock(t)
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
refresher := newTestRefresher(t, mClock)
|
||||
worker := gitsync.NewWorker(store, refresher, pub, mClock, logger)
|
||||
|
||||
result, err := worker.RefreshChat(ctx, row, ownerID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, chatID, result.ChatID)
|
||||
assert.Equal(t, upsertedStatus.Url, result.Url)
|
||||
assert.True(t, publishCalled.Load(), "publish should have been called")
|
||||
}
|
||||
|
||||
func TestRefreshChat_NoPR(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
chatID := uuid.New()
|
||||
ownerID := uuid.New()
|
||||
|
||||
row := database.ChatDiffStatus{
|
||||
ChatID: chatID,
|
||||
GitBranch: "feature",
|
||||
GitRemoteOrigin: "https://github.com/owner/repo",
|
||||
}
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
store := dbmock.NewMockStore(ctrl)
|
||||
// UpsertChatDiffStatus should NOT be called.
|
||||
|
||||
var publishCalled atomic.Bool
|
||||
pub := func(_ context.Context, _ uuid.UUID) error {
|
||||
publishCalled.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
mClock := quartz.NewMock(t)
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
|
||||
// ResolveBranchPullRequest returns nil → no PR exists yet.
|
||||
refresher := newTestRefresher(t, mClock, withResolveBranchPR(
|
||||
func(context.Context, string, gitprovider.BranchRef) (*gitprovider.PRRef, error) {
|
||||
return nil, nil
|
||||
},
|
||||
))
|
||||
worker := gitsync.NewWorker(store, refresher, pub, mClock, logger)
|
||||
|
||||
result, err := worker.RefreshChat(ctx, row, ownerID)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, result, "result should be nil when no PR exists")
|
||||
assert.False(t, publishCalled.Load(), "publish should not be called when no PR exists")
|
||||
}
|
||||
|
||||
func TestRefreshChat_RefreshError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
chatID := uuid.New()
|
||||
ownerID := uuid.New()
|
||||
|
||||
row := database.ChatDiffStatus{
|
||||
ChatID: chatID,
|
||||
Url: sql.NullString{String: "https://github.com/org/repo/pull/1", Valid: true},
|
||||
GitBranch: "feature",
|
||||
GitRemoteOrigin: "https://github.com/owner/repo",
|
||||
}
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
store := dbmock.NewMockStore(ctrl)
|
||||
// UpsertChatDiffStatus should NOT be called.
|
||||
|
||||
// Provider resolver returns nil → "no provider" error.
|
||||
providers := func(string) gitprovider.Provider { return nil }
|
||||
tokens := func(context.Context, uuid.UUID, string) (*string, error) {
|
||||
return ptr.Ref("tok"), nil
|
||||
}
|
||||
|
||||
mClock := quartz.NewMock(t)
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
refresher := gitsync.NewRefresher(providers, tokens, logger, mClock)
|
||||
worker := gitsync.NewWorker(store, refresher, nil, mClock, logger)
|
||||
|
||||
result, err := worker.RefreshChat(ctx, row, ownerID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no provider")
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestRefreshChat_UpsertError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
chatID := uuid.New()
|
||||
ownerID := uuid.New()
|
||||
|
||||
row := database.ChatDiffStatus{
|
||||
ChatID: chatID,
|
||||
GitBranch: "feature",
|
||||
GitRemoteOrigin: "https://github.com/owner/repo",
|
||||
}
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
store := dbmock.NewMockStore(ctrl)
|
||||
|
||||
store.EXPECT().UpsertChatDiffStatus(gomock.Any(), gomock.Any()).
|
||||
Return(database.ChatDiffStatus{}, fmt.Errorf("db write error"))
|
||||
|
||||
var publishCalled atomic.Bool
|
||||
pub := func(_ context.Context, _ uuid.UUID) error {
|
||||
publishCalled.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
mClock := quartz.NewMock(t)
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
refresher := newTestRefresher(t, mClock)
|
||||
worker := gitsync.NewWorker(store, refresher, pub, mClock, logger)
|
||||
|
||||
result, err := worker.RefreshChat(ctx, row, ownerID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "upsert chat diff status")
|
||||
assert.Nil(t, result)
|
||||
assert.False(t, publishCalled.Load(), "publish should not be called when upsert fails")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user