Peter Sanchez: 1 Add tests to cover adding and updating organizations in both the API and web app spaces. 6 files changed, 480 insertions(+), 0 deletions(-)
Copy & paste the following snippet into your terminal to import this patchset into git:
curl -s https://lists.code.netlandish.com/~netlandish/links-dev/patches/191/mbox | git am -3Learn more about email & git
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
Applied. To git@git.code.netlandish.com:~netlandish/links eef93b7..fc421d4 master -> master