~netlandish/links-dev

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
1

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

Details
Message ID
<20251002135135.21992-1-peter@netlandish.com>
Sender timestamp
1759391493
DKIM signature
missing
Download raw message
Patch: +480 -0
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
Details
Message ID
<DD7VXNPG1REA.2ZUH67RGN2P4B@netlandish.com>
In-Reply-To
<20251002135135.21992-1-peter@netlandish.com> (view parent)
Sender timestamp
1759391621
DKIM signature
missing
Download raw message
Applied.

To git@git.code.netlandish.com:~netlandish/links
   eef93b7..fc421d4  master -> master
Reply to thread Export thread (mbox)