Received: from mail.netlandish.com (mail.netlandish.com [174.136.98.166]) by code.netlandish.com (Postfix) with ESMTP id 12D44210 for <~netlandish/links-dev@lists.code.netlandish.com>; Thu, 02 Oct 2025 13:50:29 +0000 (UTC) Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=74.125.224.43; helo=mail-yx1-f43.google.com; envelope-from=peter@netlandish.com; receiver= Authentication-Results: mail.netlandish.com; dkim=pass (1024-bit key; unprotected) header.d=netlandish.com header.i=@netlandish.com header.b=c9Sajxx5 Received: from mail-yx1-f43.google.com (mail-yx1-f43.google.com [74.125.224.43]) by mail.netlandish.com (Postfix) with ESMTP id 45BE81D81A1 for <~netlandish/links-dev@lists.code.netlandish.com>; Thu, 02 Oct 2025 13:51:39 +0000 (UTC) Received: by mail-yx1-f43.google.com with SMTP id 956f58d0204a3-63b95594efeso114793d50.3 for <~netlandish/links-dev@lists.code.netlandish.com>; Thu, 02 Oct 2025 06:51:38 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=netlandish.com; s=google; t=1759413098; x=1760017898; darn=lists.code.netlandish.com; h=content-transfer-encoding:mime-version:message-id:date:subject:cc :to:from:from:to:cc:subject:date:message-id:reply-to; bh=49huJTbxks3B7hoFlM/YqpdVk9Oalm/F4HwulLD7ElQ=; b=c9Sajxx5kEtlE9Yq1WmVOGgLZk4omzAmNRbNPuDBcGVeXQ+mzDKnA0GN6hWlQQxoWB OB1OgJ9b3krKgMtMbmDApZqrXYcYvvHkyouu3iZmcI5dH7PReZ+d+CD0JajcKo1hmqqo MqKdQ53YqAN78PApj+oWR9lWxiv1oZpKzF+Uo= X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20230601; t=1759413098; x=1760017898; h=content-transfer-encoding:mime-version:message-id:date:subject:cc :to:from:x-gm-message-state:from:to:cc:subject:date:message-id :reply-to; bh=49huJTbxks3B7hoFlM/YqpdVk9Oalm/F4HwulLD7ElQ=; b=gBxgIDbwe56fcUz1faTFpHKIvYgoSUY/UQ0fN1GxmXihNV65158xsqZg9KpSe8LlcM PCf1XjHqtuUaLrPTS12yQIMB+/olKGYZIh9ZbbOVF3A5jFe/VDX/xnIACdirZIF3md+B 1CkrZvtPJMwbroj2+GHqfdIUKVjv3mTVjs2WsHDdMsmDDJmp8BrhpoyE8gykt+y5Y1p3 noZuHKAcWcMybplwjdQor4AYmmkLiH2/BmBi9ZTpzVXVt0W2Vq6PghW3AQfBIno1Ioag 9zRKCb4tykRgu1asKsYTMhaOZYyrwDWc14kPk7OPyccg3/ZAPJ0KB3+cE8lCh1axDKcp JTTA== X-Gm-Message-State: AOJu0YxXx2OlGpnRbeH8rnBGwcguci4EEY20Cit85uSP1eC+nUfv5BlK tGbgX5k78A1cFxS3TT7ZmpoGdFADvX0LANYMqsJVFpE0A7jkPpddm8QabUn2gv4/Ize+qcZUdUG NpYvKSNo= X-Gm-Gg: ASbGncuaeZQHw0JKTTro8t7p6w/sH0u5jvpeZwrGBZgkQjuZvZTnyuQ/8iWLJA+KA+k fCUsy0DKrfzAxiAuGxKXQVP3DhZk1BQtIlKa/dHf5w2U/wj6Y/h8qL8Zs+BPHv9Vpra8gQOEAqA vGNezvnwkLL5jfQanrRU3Mc5KtL7nUhS/H4fgytF2qdWK1Cbk7sKbErO89oLRhr7lnTv4uQVd94 hBHrod2+j3bwYmC00wltnabPhoihFmGY7iIggomdSW2IIH3k3zSLOnQcWEBrZGfv3V3pqJ1NWSL N+7ySvc+SAf8Jq+IkPxJu8EFccl2SxUHdqScUMlPDARC9hiwOnkzANSYwGsLgEWGEXU+VXaQ8c2 85LeKjCoIyLd25JOdbICQEW1RNRa9I0iXdBIeUau1dCzOAjk= X-Google-Smtp-Source: AGHT+IF1NXXnL/Jf/gzSlNupGaKQ/Wa8+57QX/BhVZ2rQQmAs6m886DCb1eSrFVPeFbHXzfxn/K1PQ== X-Received: by 2002:a05:690e:161a:b0:633:ac5d:2a03 with SMTP id 956f58d0204a3-63b6fe8c8ecmr6968038d50.6.1759413097846; Thu, 02 Oct 2025 06:51:37 -0700 (PDT) Received: from localhost ([2803:2d60:1107:87f:1ed8:9eff:1f1c:56e1]) by smtp.gmail.com with UTF8SMTPSA id 956f58d0204a3-63b84690be9sm751006d50.22.2025.10.02.06.51.37 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Thu, 02 Oct 2025 06:51:37 -0700 (PDT) From: Peter Sanchez To: ~netlandish/links-dev@lists.code.netlandish.com Cc: Peter Sanchez Subject: [PATCH links] Add tests to cover adding and updating organizations in both the API and web app spaces. Date: Thu, 2 Oct 2025 07:51:33 -0600 Message-ID: <20251002135135.21992-1-peter@netlandish.com> X-Mailer: git-send-email 2.49.1 MIME-Version: 1.0 Content-Transfer-Encoding: 8bit Changelog-added: Unit tests for adding and updating organizations (api and web app) --- api/api_test.go | 256 ++++++++++++++++++++ core/routes_test.go | 164 +++++++++++++ core/samples/add_organization.json | 21 ++ core/samples/add_organization_error.json | 11 + core/samples/update_organization.json | 17 ++ core/samples/update_organization_error.json | 11 + 6 files changed, 480 insertions(+) create mode 100644 core/samples/add_organization.json create mode 100644 core/samples/add_organization_error.json create mode 100644 core/samples/update_organization.json create mode 100644 core/samples/update_organization_error.json diff --git a/api/api_test.go b/api/api_test.go index ee0bf90..bc2820e 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -207,6 +207,262 @@ func TestDirective(t *testing.T) { }) + t.Run("add organization", func(t *testing.T) { + user3 := test.NewTestUser(3, false, false, true, true) + request := httptest.NewRequest(http.MethodPost, "/query", nil) + request = request.WithContext(dbCtx) + request = request.WithContext((crypto.Context(request.Context(), entropy))) + recoder := httptest.NewRecorder() + ctx3 := &server.Context{ + Server: srv, + Context: e.NewContext(request, recoder), + User: user3, + } + + type GraphQLResponse struct{ + Org models.Organization `json:"addOrganization"` + } + var result GraphQLResponse + op := gqlclient.NewOperation( + `mutation AddOrganization($name: String!, $username: String!) { + addOrganization(input: { + name: $name, + orgUsername: $username, + }) { + id + name + slug + ownerId + visibility + settings { + billing { + status + } + } + } + }`) + op.Var("name", "New Test Organization") + op.Var("username", "new-test-org") + + accessToken, err := test.NewAccessTokenTest(ctx3, int(user3.ID), "ORGS:RW") + c.NoError(err) + client := test.NewClientTest(accessToken) + err = client.Execute(ctx3.Request().Context(), op, &result) + c.NoError(err) + + c.Equal("New Test Organization", result.Org.Name) + c.Equal("new-test-org", result.Org.Slug) + c.Equal(int(user3.ID), result.Org.OwnerID) + c.Equal(models.VisibilityPublic, result.Org.Visibility) + c.Equal(models.BillingStatusFree, result.Org.Settings.Billing.Status) + + opts := &database.FilterOptions{ + Filter: sq.Expr("o.id = ?", result.Org.ID), + Limit: 1, + } + orgs, err := models.GetOrganizations(dbCtx, opts) + c.NoError(err) + c.Equal(1, len(orgs)) + c.Equal(models.VisibilityPublic, orgs[0].Visibility) + }) + + t.Run("add organization - validation errors", func(t *testing.T) { + user3 := test.NewTestUser(3, false, false, true, true) + request := httptest.NewRequest(http.MethodPost, "/query", nil) + request = request.WithContext(dbCtx) + request = request.WithContext((crypto.Context(request.Context(), entropy))) + recoder := httptest.NewRecorder() + ctx3 := &server.Context{ + Server: srv, + Context: e.NewContext(request, recoder), + User: user3, + } + + type GraphQLResponse struct { + Org models.Organization `json:"addOrganization"` + } + var result GraphQLResponse + + accessToken, err := test.NewAccessTokenTest(ctx3, int(user3.ID), "ORGS:RW") + c.NoError(err) + client := test.NewClientTest(accessToken) + + op := gqlclient.NewOperation( + `mutation AddOrganization($name: String!, $username: String!) { + addOrganization(input: { + name: $name, + orgUsername: $username, + }) { + id + } + }`) + op.Var("name", "") + op.Var("username", "test") + + err = client.Execute(ctx3.Request().Context(), op, &result) + c.Error(err) + c.Contains(err.Error(), "Name is required") + + op = gqlclient.NewOperation( + `mutation AddOrganization($name: String!, $username: String!) { + addOrganization(input: { + name: $name, + orgUsername: $username, + }) { + id + } + }`) + op.Var("name", "Test") + op.Var("username", "") + + err = client.Execute(ctx3.Request().Context(), op, &result) + c.Error(err) + c.Contains(err.Error(), "Org username is required") + + op = gqlclient.NewOperation( + `mutation AddOrganization($name: String!, $username: String!) { + addOrganization(input: { + name: $name, + orgUsername: $username, + }) { + id + } + }`) + op.Var("name", "Test") + op.Var("username", "new-test-org") + + err = client.Execute(ctx3.Request().Context(), op, &result) + c.Error(err) + c.Contains(err.Error(), "already registered") + }) + + t.Run("update organization", func(t *testing.T) { + user3 := test.NewTestUser(3, false, false, true, true) + request := httptest.NewRequest(http.MethodPost, "/query", nil) + request = request.WithContext(dbCtx) + request = request.WithContext((crypto.Context(request.Context(), entropy))) + recoder := httptest.NewRecorder() + ctx3 := &server.Context{ + Server: srv, + Context: e.NewContext(request, recoder), + User: user3, + } + + testOrg := &models.Organization{ + OwnerID: int(user3.ID), + OrgType: models.OrgTypeNormal, + Name: "Update Test Org", + Slug: "update-test-org", + IsActive: true, + Visibility: models.VisibilityPublic, + Settings: models.OrganizationSettings{ + DefaultPerm: models.OrgLinkVisibilityPublic, + Billing: models.BillingSettings{ + Status: models.BillingStatusFree, + }, + }, + } + err := testOrg.Store(dbCtx) + c.NoError(err) + + type GraphQLResponse struct { + Org models.Organization `json:"updateOrganization"` + } + var result GraphQLResponse + op := gqlclient.NewOperation( + `mutation UpdateOrganization($currentSlug: String!, $name: String!, + $slug: String!, $perm: LinkVisibility, $isActive: Boolean) { + updateOrganization(input: { + currentSlug: $currentSlug, + name: $name, + slug: $slug, + defaultPerm: $perm, + isActive: $isActive, + }) { + id + name + slug + isActive + visibility + settings { + billing { + status + } + } + } + }`) + op.Var("currentSlug", "update-test-org") + op.Var("name", "Updated Name") + op.Var("slug", "updated-slug") + op.Var("perm", models.OrgLinkVisibilityPublic) + op.Var("isActive", true) + + accessToken, err := test.NewAccessTokenTest(ctx3, int(user3.ID), "ORGS:RW") + c.NoError(err) + client := test.NewClientTest(accessToken) + err = client.Execute(ctx3.Request().Context(), op, &result) + c.NoError(err) + + c.Equal("Updated Name", result.Org.Name) + c.Equal("updated-slug", result.Org.Slug) + c.Equal(true, result.Org.IsActive) + + opts := &database.FilterOptions{ + Filter: sq.Expr("o.id = ?", testOrg.ID), + Limit: 1, + } + orgs, err := models.GetOrganizations(dbCtx, opts) + c.NoError(err) + c.Equal(1, len(orgs)) + c.Equal("Updated Name", orgs[0].Name) + c.Equal("updated-slug", orgs[0].Slug) + }) + + t.Run("update organization - not owner", func(t *testing.T) { + user2 := test.NewTestUser(2, false, false, true, true) + testOrg := &models.Organization{ + OwnerID: int(user2.ID), + OrgType: models.OrgTypeNormal, + Name: "Someone Else Org", + Slug: "someone-else-org", + IsActive: true, + Visibility: models.VisibilityPublic, + Settings: models.OrganizationSettings{ + DefaultPerm: models.OrgLinkVisibilityPublic, + Billing: models.BillingSettings{ + Status: models.BillingStatusFree, + }, + }, + } + err := testOrg.Store(dbCtx) + c.NoError(err) + + type GraphQLResponse struct { + Org models.Organization `json:"updateOrganization"` + } + var result GraphQLResponse + op := gqlclient.NewOperation( + `mutation UpdateOrganization($currentSlug: String!, $name: String!, $slug: String!) { + updateOrganization(input: { + currentSlug: $currentSlug, + name: $name, + slug: $slug, + }) { + id + } + }`) + op.Var("currentSlug", "someone-else-org") + op.Var("name", "Hacked Name") + op.Var("slug", "hacked-slug") + + accessToken, err := test.NewAccessTokenTest(ctx, int(user.ID), "ORGS:RW") + c.NoError(err) + client := test.NewClientTest(accessToken) + err = client.Execute(ctx.Request().Context(), op, &result) + c.Error(err) + c.Contains(err.Error(), "Not Found") + }) + t.Run("short", func(t *testing.T) { type GraphQLResponse struct { LinkShorts struct { diff --git a/core/routes_test.go b/core/routes_test.go index 452931c..70eff34 100644 --- a/core/routes_test.go +++ b/core/routes_test.go @@ -1,6 +1,7 @@ package core_test import ( + "bytes" "database/sql" "fmt" "links" @@ -8,6 +9,7 @@ import ( "links/cmd/test" "links/core" "links/models" + "mime/multipart" "net/http" "net/http/httptest" "net/url" @@ -634,4 +636,166 @@ func TestHandlers(t *testing.T) { c.True(ok) c.Equal(http.StatusNotFound, httpErr.Code) }) + + t.Run("organization create GET", func(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + jsonResponse, err := httpmock.NewJsonResponder(http.StatusOK, httpmock.File("samples/org_list.json")) + c.NoError(err) + httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query", jsonResponse) + + request := httptest.NewRequest(http.MethodGet, "/organization/add", nil) + recorder := httptest.NewRecorder() + ctx := &server.Context{ + Server: srv, + Context: e.NewContext(request, recorder), + User: loggedInUser, + } + ctx.SetPath("/organization/add") + err = test.MakeRequestWithDomain(srv, coreService.OrgCreate, ctx, domains[0]) + c.NoError(err) + c.Equal(http.StatusOK, recorder.Code) + htmlBody := recorder.Body.String() + c.True(strings.Contains(htmlBody, "Create Organization")) + }) + + t.Run("organization create POST - success", func(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + jsonResponse, err := httpmock.NewJsonResponder(http.StatusOK, httpmock.File("samples/add_organization.json")) + c.NoError(err) + httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query", jsonResponse) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + writer.WriteField("name", "Test Organization") + writer.WriteField("org_username", "test-org") + writer.Close() + + request := httptest.NewRequest(http.MethodPost, "/organization/add", body) + request.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) + recorder := httptest.NewRecorder() + ctx := &server.Context{ + Server: srv, + Context: e.NewContext(request, recorder), + User: loggedInUser, + } + ctx.SetPath("/organization/add") + err = test.MakeRequestWithDomain(srv, coreService.OrgCreate, ctx, domains[0]) + c.NoError(err) + c.Equal(http.StatusMovedPermanently, recorder.Code) + }) + + t.Run("organization create POST - error", func(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + jsonResponse, err := httpmock.NewJsonResponder(http.StatusOK, httpmock.File("samples/add_organization_error.json")) + c.NoError(err) + httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query", jsonResponse) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + writer.WriteField("name", "Test") + writer.WriteField("org_username", "personal-org") + writer.Close() + + request := httptest.NewRequest(http.MethodPost, "/organization/add", body) + request.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) + recorder := httptest.NewRecorder() + ctx := &server.Context{ + Server: srv, + Context: e.NewContext(request, recorder), + User: loggedInUser, + } + ctx.SetPath("/organization/add") + err = test.MakeRequestWithDomain(srv, coreService.OrgCreate, ctx, domains[0]) + c.NoError(err) + // Status OK means the form was re-rendered with errors (not redirected) + c.Equal(http.StatusOK, recorder.Code) + htmlBody := recorder.Body.String() + // Verify the create organization form is shown (indicating error handling) + c.True(strings.Contains(htmlBody, "Create Organization")) + }) + + t.Run("organization update GET", func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/personal-org/edit", nil) + recorder := httptest.NewRecorder() + ctx := &server.Context{ + Server: srv, + Context: e.NewContext(request, recorder), + User: loggedInUser, + } + ctx.SetPath("/:slug/edit") + ctx.SetParamNames("slug") + ctx.SetParamValues("personal-org") + err := test.MakeRequestWithDomain(srv, coreService.OrgUpdate, ctx, domains[0]) + c.NoError(err) + c.Equal(http.StatusOK, recorder.Code) + htmlBody := recorder.Body.String() + c.True(strings.Contains(htmlBody, "Name")) + c.True(strings.Contains(htmlBody, "personal org")) + }) + + t.Run("organization update POST - success", func(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + jsonResponse, err := httpmock.NewJsonResponder(http.StatusOK, httpmock.File("samples/update_organization.json")) + c.NoError(err) + httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query", jsonResponse) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + writer.WriteField("name", "Updated Org Name") + writer.WriteField("slug", "updated-slug") + writer.WriteField("default_perm", "PRIVATE") + writer.Close() + + request := httptest.NewRequest(http.MethodPost, "/personal-org/edit", body) + request.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) + recorder := httptest.NewRecorder() + ctx := &server.Context{ + Server: srv, + Context: e.NewContext(request, recorder), + User: loggedInUser, + } + ctx.SetPath("/:slug/edit") + ctx.SetParamNames("slug") + ctx.SetParamValues("personal-org") + err = test.MakeRequestWithDomain(srv, coreService.OrgUpdate, ctx, domains[0]) + c.NoError(err) + c.Equal(http.StatusMovedPermanently, recorder.Code) + }) + + t.Run("organization update POST - error", func(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + jsonResponse, err := httpmock.NewJsonResponder(http.StatusOK, httpmock.File("samples/update_organization_error.json")) + c.NoError(err) + httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query", jsonResponse) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + writer.WriteField("name", "Test") + writer.WriteField("slug", "business_org") + writer.Close() + + request := httptest.NewRequest(http.MethodPost, "/personal-org/edit", body) + request.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) + recorder := httptest.NewRecorder() + ctx := &server.Context{ + Server: srv, + Context: e.NewContext(request, recorder), + User: loggedInUser, + } + ctx.SetPath("/:slug/edit") + ctx.SetParamNames("slug") + ctx.SetParamValues("personal-org") + err = test.MakeRequestWithDomain(srv, coreService.OrgUpdate, ctx, domains[0]) + c.NoError(err) + // Status OK means the form was re-rendered with errors (not redirected) + c.Equal(http.StatusOK, recorder.Code) + htmlBody := recorder.Body.String() + // Verify the form is shown (indicating error handling) + c.True(len(htmlBody) > 0) + }) } diff --git a/core/samples/add_organization.json b/core/samples/add_organization.json new file mode 100644 index 0000000..2e5cb71 --- /dev/null +++ b/core/samples/add_organization.json @@ -0,0 +1,21 @@ +{ + "data": { + "addOrganization": { + "id": 100, + "name": "Test Organization", + "slug": "test-org", + "ownerId": 1, + "orgType": "NORMAL", + "isActive": true, + "visibility": "PUBLIC", + "settings": { + "billing": { + "status": "FREE" + }, + "defaultPerm": "PUBLIC" + }, + "createdOn": "2025-01-15T10:00:00Z", + "updatedOn": "2025-01-15T10:00:00Z" + } + } +} diff --git a/core/samples/add_organization_error.json b/core/samples/add_organization_error.json new file mode 100644 index 0000000..cce7993 --- /dev/null +++ b/core/samples/add_organization_error.json @@ -0,0 +1,11 @@ +{ + "errors": [ + { + "message": "This organization slug is already registered", + "extensions": { + "code": 100, + "field": "orgUsername" + } + } + ] +} diff --git a/core/samples/update_organization.json b/core/samples/update_organization.json new file mode 100644 index 0000000..50921f0 --- /dev/null +++ b/core/samples/update_organization.json @@ -0,0 +1,17 @@ +{ + "data": { + "updateOrganization": { + "id": 1, + "name": "Updated Org Name", + "slug": "updated-slug", + "isActive": true, + "settings": { + "billing": { + "status": "FREE" + }, + "defaultPerm": "PRIVATE" + }, + "updatedOn": "2025-01-15T10:30:00Z" + } + } +} diff --git a/core/samples/update_organization_error.json b/core/samples/update_organization_error.json new file mode 100644 index 0000000..9f408ca --- /dev/null +++ b/core/samples/update_organization_error.json @@ -0,0 +1,11 @@ +{ + "errors": [ + { + "message": "This organization slug is already registered", + "extensions": { + "code": 100, + "field": "slug" + } + } + ] +} -- 2.49.1