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