Skip to content

Commit 5712589

Browse files
committed
Add additional tests after updating dbt cloud API notification settings
endpoints
1 parent 7c10923 commit 5712589

2 files changed

Lines changed: 364 additions & 9 deletions

File tree

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
package dbt_cloud_test
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"testing"
11+
12+
"github.com/dbt-labs/terraform-provider-dbtcloud/pkg/dbt_cloud"
13+
"github.com/dbt-labs/terraform-provider-dbtcloud/pkg/dbt_cloud/testutil"
14+
)
15+
16+
// These tests pin down the contract between the dbt Cloud notification-settings
17+
// endpoint and the Terraform provider's recovery behavior. The Read handler
18+
// relies on HandleResourceNotFound recognizing a "resource-not-found"-prefixed
19+
// error to drop a missing resource from state. If the API returns anything else
20+
// — 500, 502, or any other 5xx — the provider must surface it instead of
21+
// silently reconciling, otherwise users get either ghost resources in state or
22+
// silent deletions of resources that still exist server-side.
23+
//
24+
// Background: a customer hit a 502 on DELETE followed by a 500 on the next
25+
// Refresh because the backend was mapping a Django DoesNotExist to a generic
26+
// 500 instead of 404. After the backend fix, GET on a missing setting returns
27+
// 404 and Terraform reconciles cleanly. These tests lock that contract in.
28+
29+
const (
30+
testAccountID int64 = 123
31+
testNotificationID int64 = 456
32+
testPrivateAPISegment = "/private/accounts/123/notification-settings"
33+
)
34+
35+
// notificationSettingMockServer is a configurable test double for the
36+
// /private/accounts/:id/notification-settings endpoint. Each method returns
37+
// the configured status code (and body) on a single matching call. Default
38+
// status is 200 with an empty success body, so unconfigured callers will still
39+
// hit a deterministic path rather than panicking.
40+
type notificationSettingMockServer struct {
41+
t *testing.T
42+
server *httptest.Server
43+
44+
getStatus int
45+
getBody string
46+
deleteStatus int
47+
deleteBody string
48+
postStatus int
49+
postBody string
50+
patchStatus int
51+
patchBody string
52+
53+
lastRequestMethod string
54+
lastRequestPath string
55+
lastRequestBody []byte
56+
}
57+
58+
func newNotificationSettingMockServer(t *testing.T) *notificationSettingMockServer {
59+
m := &notificationSettingMockServer{
60+
t: t,
61+
getStatus: http.StatusOK,
62+
deleteStatus: http.StatusNoContent,
63+
postStatus: http.StatusCreated,
64+
patchStatus: http.StatusOK,
65+
}
66+
m.server = httptest.NewServer(http.HandlerFunc(m.serve))
67+
return m
68+
}
69+
70+
func (m *notificationSettingMockServer) serve(w http.ResponseWriter, r *http.Request) {
71+
body, _ := io.ReadAll(r.Body)
72+
m.lastRequestMethod = r.Method
73+
m.lastRequestPath = r.URL.Path
74+
m.lastRequestBody = body
75+
76+
if !strings.HasPrefix(r.URL.Path, testPrivateAPISegment) {
77+
http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound)
78+
return
79+
}
80+
81+
w.Header().Set("Content-Type", "application/json")
82+
83+
switch r.Method {
84+
case http.MethodGet:
85+
w.WriteHeader(m.getStatus)
86+
_, _ = w.Write([]byte(m.getBody))
87+
case http.MethodDelete:
88+
w.WriteHeader(m.deleteStatus)
89+
_, _ = w.Write([]byte(m.deleteBody))
90+
case http.MethodPost:
91+
w.WriteHeader(m.postStatus)
92+
_, _ = w.Write([]byte(m.postBody))
93+
case http.MethodPatch:
94+
w.WriteHeader(m.patchStatus)
95+
_, _ = w.Write([]byte(m.patchBody))
96+
default:
97+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
98+
}
99+
}
100+
101+
func (m *notificationSettingMockServer) close() { m.server.Close() }
102+
103+
func (m *notificationSettingMockServer) client() *dbt_cloud.Client {
104+
return testutil.CreateTestClient(m.server.URL, testAccountID)
105+
}
106+
107+
// not-found-style errors are the only ones HandleResourceNotFound recognizes.
108+
// All other errors must propagate to Terraform.
109+
func errorIsNotFound(err error) bool {
110+
return err != nil && strings.HasPrefix(err.Error(), "resource-not-found")
111+
}
112+
113+
func TestGetNotificationSetting_Success(t *testing.T) {
114+
srv := newNotificationSettingMockServer(t)
115+
defer srv.close()
116+
117+
srv.getStatus = http.StatusOK
118+
srv.getBody = `{
119+
"status": {"code": 200, "is_success": true},
120+
"data": {
121+
"id": 456,
122+
"account_id": 123,
123+
"name": "weekly digest",
124+
"description": "send teams pings",
125+
"is_active": true,
126+
"channels": [{"id": 1, "channel_type": "teams"}],
127+
"rules": [{"id": 2, "trigger_on": "run_errored"}]
128+
}
129+
}`
130+
131+
got, err := srv.client().GetNotificationSetting(testNotificationID)
132+
if err != nil {
133+
t.Fatalf("unexpected error: %v", err)
134+
}
135+
if got == nil || got.ID == nil || *got.ID != testNotificationID {
136+
t.Fatalf("expected ID %d, got %+v", testNotificationID, got)
137+
}
138+
if got.Name != "weekly digest" {
139+
t.Errorf("expected name %q, got %q", "weekly digest", got.Name)
140+
}
141+
if len(got.Channels) != 1 || len(got.Rules) != 1 {
142+
t.Errorf("expected one channel and one rule, got %d channels and %d rules", len(got.Channels), len(got.Rules))
143+
}
144+
}
145+
146+
// Regression test: post-backend-fix, a missing setting returns 404 and the
147+
// provider must surface that as a "resource-not-found" error so the Read
148+
// handler removes the resource from state via HandleResourceNotFound.
149+
func TestGetNotificationSetting_404_IsRecognizedAsResourceNotFound(t *testing.T) {
150+
srv := newNotificationSettingMockServer(t)
151+
defer srv.close()
152+
153+
srv.getStatus = http.StatusNotFound
154+
srv.getBody = `{
155+
"status": {
156+
"code": 404,
157+
"is_success": false,
158+
"user_message": "Notification setting not found"
159+
},
160+
"data": null
161+
}`
162+
163+
_, err := srv.client().GetNotificationSetting(testNotificationID)
164+
if err == nil {
165+
t.Fatal("expected an error, got nil")
166+
}
167+
if !errorIsNotFound(err) {
168+
t.Fatalf("expected resource-not-found error, got %q", err.Error())
169+
}
170+
}
171+
172+
// Regression test: before the backend fix, a missing setting surfaced as a
173+
// generic 500. The provider must NOT silently reconcile that — it should
174+
// propagate the error so Terraform retries or the user investigates, rather
175+
// than dropping a resource from state that may still exist server-side.
176+
//
177+
// This is the exact path that stranded Aramark: Refresh hit a 500 and the
178+
// provider correctly refused to drop the resource. The test pins down that
179+
// behavior so a future "just treat any 5xx as gone" change can't regress it.
180+
func TestGetNotificationSetting_500_IsNotMaskedAsNotFound(t *testing.T) {
181+
srv := newNotificationSettingMockServer(t)
182+
defer srv.close()
183+
184+
srv.getStatus = http.StatusInternalServerError
185+
srv.getBody = `{
186+
"status": {
187+
"code": 500,
188+
"is_success": false,
189+
"user_message": "Internal server error getting notification setting"
190+
}
191+
}`
192+
193+
_, err := srv.client().GetNotificationSetting(testNotificationID)
194+
if err == nil {
195+
t.Fatal("expected an error, got nil")
196+
}
197+
if errorIsNotFound(err) {
198+
t.Fatalf("500 must not be classified as resource-not-found, got %q", err.Error())
199+
}
200+
if !strings.Contains(err.Error(), "internal-server-error") {
201+
t.Errorf("expected internal-server-error tag in error, got %q", err.Error())
202+
}
203+
}
204+
205+
// Regression test: matches the customer-reported failure mode — DELETE
206+
// returned 502 from the gateway. The next Refresh re-fetched via GET, which
207+
// the gateway also dropped with a 502 protocol error. That must propagate
208+
// (not be silently reconciled) so Terraform retries.
209+
func TestGetNotificationSetting_502_IsNotMaskedAsNotFound(t *testing.T) {
210+
srv := newNotificationSettingMockServer(t)
211+
defer srv.close()
212+
213+
srv.getStatus = http.StatusBadGateway
214+
srv.getBody = "upstream connect error or disconnect/reset before headers"
215+
216+
_, err := srv.client().GetNotificationSetting(testNotificationID)
217+
if err == nil {
218+
t.Fatal("expected an error, got nil")
219+
}
220+
if errorIsNotFound(err) {
221+
t.Fatalf("502 must not be classified as resource-not-found, got %q", err.Error())
222+
}
223+
if !strings.Contains(err.Error(), "502") {
224+
t.Errorf("expected the 502 status to appear in the error, got %q", err.Error())
225+
}
226+
}
227+
228+
func TestDeleteNotificationSetting_Success(t *testing.T) {
229+
srv := newNotificationSettingMockServer(t)
230+
defer srv.close()
231+
232+
srv.deleteStatus = http.StatusNoContent
233+
234+
if err := srv.client().DeleteNotificationSetting(testNotificationID); err != nil {
235+
t.Fatalf("unexpected error: %v", err)
236+
}
237+
if srv.lastRequestMethod != http.MethodDelete {
238+
t.Errorf("expected DELETE, got %s", srv.lastRequestMethod)
239+
}
240+
wantPath := fmt.Sprintf("%s/%d/", testPrivateAPISegment, testNotificationID)
241+
if srv.lastRequestPath != wantPath {
242+
t.Errorf("expected path %q, got %q", wantPath, srv.lastRequestPath)
243+
}
244+
}
245+
246+
// Regression test: after the backend fix, DELETE on a missing setting returns
247+
// 404 (mapped from Django DoesNotExist). The provider currently surfaces this
248+
// as an error from Delete — that's fine, since Terraform's destroy plan would
249+
// not have been re-run if the resource was already gone. What matters is that
250+
// it doesn't crash and the error string is recognizable.
251+
func TestDeleteNotificationSetting_404(t *testing.T) {
252+
srv := newNotificationSettingMockServer(t)
253+
defer srv.close()
254+
255+
srv.deleteStatus = http.StatusNotFound
256+
srv.deleteBody = `{
257+
"status": {"code": 404, "is_success": false, "user_message": "Notification setting not found"},
258+
"data": null
259+
}`
260+
261+
err := srv.client().DeleteNotificationSetting(testNotificationID)
262+
if err == nil {
263+
t.Fatal("expected an error, got nil")
264+
}
265+
if !errorIsNotFound(err) {
266+
t.Fatalf("expected resource-not-found error, got %q", err.Error())
267+
}
268+
}
269+
270+
// Regression test for Aramark's original symptom: DELETE returns 502 from the
271+
// gateway. The provider must surface the 502 so the user knows the destroy
272+
// did not complete successfully — silently swallowing it would either leave
273+
// orphaned server-side state or cause Terraform to think the resource is
274+
// gone when it isn't.
275+
func TestDeleteNotificationSetting_502_Surfaces(t *testing.T) {
276+
srv := newNotificationSettingMockServer(t)
277+
defer srv.close()
278+
279+
srv.deleteStatus = http.StatusBadGateway
280+
srv.deleteBody = "upstream connect error or disconnect/reset before headers"
281+
282+
err := srv.client().DeleteNotificationSetting(testNotificationID)
283+
if err == nil {
284+
t.Fatal("expected a 502 error, got nil")
285+
}
286+
if !strings.Contains(err.Error(), "502") {
287+
t.Errorf("expected 502 in error, got %q", err.Error())
288+
}
289+
}
290+
291+
func TestCreateNotificationSetting_Success(t *testing.T) {
292+
srv := newNotificationSettingMockServer(t)
293+
defer srv.close()
294+
295+
srv.postStatus = http.StatusCreated
296+
srv.postBody = `{
297+
"status": {"code": 201, "is_success": true},
298+
"data": {
299+
"id": 456,
300+
"name": "alerts",
301+
"is_active": true,
302+
"channels": [],
303+
"rules": []
304+
}
305+
}`
306+
307+
desc := "first"
308+
created, err := srv.client().CreateNotificationSetting(dbt_cloud.NotificationSetting{
309+
Name: "alerts",
310+
Description: &desc,
311+
IsActive: true,
312+
AccountID: 999, // The client should strip this; the URL path carries the account.
313+
})
314+
if err != nil {
315+
t.Fatalf("unexpected error: %v", err)
316+
}
317+
if created == nil || created.ID == nil || *created.ID != testNotificationID {
318+
t.Fatalf("expected ID %d, got %+v", testNotificationID, created)
319+
}
320+
321+
var sent dbt_cloud.NotificationSetting
322+
if err := json.Unmarshal(srv.lastRequestBody, &sent); err != nil {
323+
t.Fatalf("could not unmarshal recorded request body: %v", err)
324+
}
325+
if sent.AccountID != 0 {
326+
t.Errorf("AccountID must be stripped from request body (URL carries it), got %d", sent.AccountID)
327+
}
328+
if sent.Name != "alerts" {
329+
t.Errorf("expected name %q, got %q", "alerts", sent.Name)
330+
}
331+
}
332+
333+
func TestUpdateNotificationSetting_Success(t *testing.T) {
334+
srv := newNotificationSettingMockServer(t)
335+
defer srv.close()
336+
337+
srv.patchStatus = http.StatusOK
338+
srv.patchBody = `{
339+
"status": {"code": 200, "is_success": true},
340+
"data": {
341+
"id": 456,
342+
"name": "renamed",
343+
"is_active": false,
344+
"channels": [],
345+
"rules": []
346+
}
347+
}`
348+
349+
updated, err := srv.client().UpdateNotificationSetting(testNotificationID, dbt_cloud.NotificationSetting{
350+
Name: "renamed",
351+
IsActive: false,
352+
})
353+
if err != nil {
354+
t.Fatalf("unexpected error: %v", err)
355+
}
356+
if updated == nil || updated.Name != "renamed" {
357+
t.Fatalf("expected updated name %q, got %+v", "renamed", updated)
358+
}
359+
if srv.lastRequestMethod != http.MethodPatch {
360+
t.Errorf("expected PATCH, got %s", srv.lastRequestMethod)
361+
}
362+
}

pkg/framework/objects/notification_setting/resource_acceptance_test.go

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -206,16 +206,9 @@ func testAccCheckDbtCloudNotificationSettingDestroy(s *terraform.State) error {
206206
return fmt.Errorf("Notification setting %s still exists", rs.Primary.ID)
207207
}
208208

209-
// The notification-settings detail GET currently returns 500 with a
210-
// generic message instead of 404 when the row has been deleted (the bare
211-
// `except Exception` block in notification_settings_views.py swallows
212-
// `DoesNotExist`). Until the backend distinguishes them, treat any 500
213-
// from the verification GET as proof of deletion — DELETE has already
214-
// returned 2xx by this point, so a follow-up gateway timeout or generic
215-
// 500 just means we can't reconfirm via GET.
216-
expectedErr := regexp.MustCompile(`resource-not-found|internal-server-error`)
209+
expectedErr := regexp.MustCompile(`resource-not-found`)
217210
if !expectedErr.Match([]byte(err.Error())) {
218-
return fmt.Errorf("expected not-found-style error, got %s", err)
211+
return fmt.Errorf("expected resource-not-found error, got %s", err)
219212
}
220213
}
221214
return nil

0 commit comments

Comments
 (0)