Subject: [PATCH links] Add tests to cover adding and updating organizations in both the API and web app spaces.

From: Peter Sanchez <[email-masked]>
Date: Thu, 2 Oct 2025 07:51:33 -0600

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