From 8d933195004edebfc349497dfcb8e8e55fea6cae Mon Sep 17 00:00:00 2001 From: Aofei Sheng Date: Fri, 27 Sep 2024 11:03:21 +0800 Subject: [PATCH] feat(spx-backend): implement Community API Fixes #920 Fixes #948 Signed-off-by: Aofei Sheng --- docs/openapi.yaml | 239 +- spx-backend/.env.dev | 2 +- .../cmd/spx-backend/delete_asset_#id.yap | 2 +- .../delete_project_#owner_#name.yap | 2 +- .../delete_project_#owner_#name_liking.yap | 16 + .../delete_user_#username_following.yap | 16 + .../cmd/spx-backend/get_assets_list.yap | 35 +- ...oject-release_#owner_#project_#release.yap | 13 + .../spx-backend/get_project-releases_list.yap | 34 + .../get_project_#owner_#name_liking.yap | 21 + .../cmd/spx-backend/get_projects_list.yap | 72 +- .../cmd/spx-backend/get_user_#username.yap | 13 + .../get_user_#username_following.yap | 21 + .../cmd/spx-backend/get_users_list.yap | 38 + spx-backend/cmd/spx-backend/gop_autogen.go | 812 ++++-- spx-backend/cmd/spx-backend/middleware.go | 2 +- spx-backend/cmd/spx-backend/post_asset.yap | 12 +- .../cmd/spx-backend/post_asset_#id_click.yap | 12 - .../cmd/spx-backend/post_project-release.yap | 30 + spx-backend/cmd/spx-backend/post_project.yap | 12 +- .../post_project_#owner_#name_liking.yap | 16 + .../post_user_#username_following.yap | 16 + .../cmd/spx-backend/post_util_fileurls.yap | 2 +- .../cmd/spx-backend/post_util_fmtcode.yap | 2 +- spx-backend/cmd/spx-backend/put_user.yap | 30 + spx-backend/cmd/spx-backend/util.go | 10 +- spx-backend/go.mod | 4 + spx-backend/go.sum | 10 + spx-backend/init.sql | 42 - spx-backend/internal/controller/asset.go | 417 +-- spx-backend/internal/controller/asset_test.go | 1390 +++++----- spx-backend/internal/controller/controller.go | 115 +- .../internal/controller/controller_test.go | 85 +- spx-backend/internal/controller/project.go | 773 +++++- .../internal/controller/project_release.go | 249 ++ .../controller/project_release_test.go | 430 +++ .../internal/controller/project_test.go | 2373 ++++++++++++++--- spx-backend/internal/controller/user.go | 347 ++- spx-backend/internal/controller/user_test.go | 1016 ++++++- spx-backend/internal/controller/util_test.go | 44 +- spx-backend/internal/model/asset.go | 133 +- spx-backend/internal/model/asset_test.go | 218 -- spx-backend/internal/model/clause.go | 65 - spx-backend/internal/model/clause_test.go | 78 - spx-backend/internal/model/file.go | 34 - spx-backend/internal/model/model.go | 107 +- .../model/{file_test.go => model_test.go} | 0 .../internal/model/modeltest/modeltest.go | 91 + spx-backend/internal/model/project.go | 110 +- spx-backend/internal/model/project_release.go | 31 + spx-backend/internal/model/project_test.go | 225 -- spx-backend/internal/model/query.go | 220 -- spx-backend/internal/model/query_test.go | 399 --- spx-backend/internal/model/scan.go | 73 - spx-backend/internal/model/scan_test.go | 174 -- spx-backend/internal/model/user.go | 66 + .../model/user_project_relationship.go | 69 + .../model/user_project_relationship_test.go | 106 + .../internal/model/user_relationship.go | 62 + .../internal/model/user_relationship_test.go | 104 + spx-backend/internal/model/user_test.go | 104 + spx-gui/src/apis/asset.ts | 51 +- spx-gui/src/apis/common/index.ts | 6 +- spx-gui/src/apis/project.ts | 58 +- spx-gui/src/apis/user.ts | 20 +- .../asset/library/AssetAddModal.vue | 25 +- .../asset/library/AssetLibraryModal.vue | 10 +- .../components/community/user/UserItem.vue | 2 +- .../community/user/header/UserHeader.vue | 2 +- .../components/editor/navbar/EditorNavbar.vue | 4 +- .../components/project/ProjectCreateModal.vue | 4 +- .../src/components/project/ProjectItem.vue | 8 +- spx-gui/src/components/project/index.ts | 12 +- spx-gui/src/models/common/asset.ts | 8 +- spx-gui/src/models/common/cloud.ts | 8 +- spx-gui/src/models/project/index.ts | 24 +- spx-gui/src/pages/community/home.vue | 2 +- spx-gui/src/pages/community/search.vue | 6 +- spx-gui/src/pages/community/user/overview.vue | 2 +- spx-gui/src/pages/community/user/projects.vue | 6 +- 80 files changed, 7820 insertions(+), 3682 deletions(-) create mode 100644 spx-backend/cmd/spx-backend/delete_project_#owner_#name_liking.yap create mode 100644 spx-backend/cmd/spx-backend/delete_user_#username_following.yap create mode 100644 spx-backend/cmd/spx-backend/get_project-release_#owner_#project_#release.yap create mode 100644 spx-backend/cmd/spx-backend/get_project-releases_list.yap create mode 100644 spx-backend/cmd/spx-backend/get_project_#owner_#name_liking.yap create mode 100644 spx-backend/cmd/spx-backend/get_user_#username.yap create mode 100644 spx-backend/cmd/spx-backend/get_user_#username_following.yap create mode 100644 spx-backend/cmd/spx-backend/get_users_list.yap delete mode 100644 spx-backend/cmd/spx-backend/post_asset_#id_click.yap create mode 100644 spx-backend/cmd/spx-backend/post_project-release.yap create mode 100644 spx-backend/cmd/spx-backend/post_project_#owner_#name_liking.yap create mode 100644 spx-backend/cmd/spx-backend/post_user_#username_following.yap create mode 100644 spx-backend/cmd/spx-backend/put_user.yap delete mode 100644 spx-backend/init.sql create mode 100644 spx-backend/internal/controller/project_release.go create mode 100644 spx-backend/internal/controller/project_release_test.go delete mode 100644 spx-backend/internal/model/asset_test.go delete mode 100644 spx-backend/internal/model/clause.go delete mode 100644 spx-backend/internal/model/clause_test.go delete mode 100644 spx-backend/internal/model/file.go rename spx-backend/internal/model/{file_test.go => model_test.go} (100%) create mode 100644 spx-backend/internal/model/modeltest/modeltest.go create mode 100644 spx-backend/internal/model/project_release.go delete mode 100644 spx-backend/internal/model/project_test.go delete mode 100644 spx-backend/internal/model/query.go delete mode 100644 spx-backend/internal/model/query_test.go delete mode 100644 spx-backend/internal/model/scan.go delete mode 100644 spx-backend/internal/model/scan_test.go create mode 100644 spx-backend/internal/model/user.go create mode 100644 spx-backend/internal/model/user_project_relationship.go create mode 100644 spx-backend/internal/model/user_project_relationship_test.go create mode 100644 spx-backend/internal/model/user_relationship.go create mode 100644 spx-backend/internal/model/user_relationship_test.go create mode 100644 spx-backend/internal/model/user_test.go diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c0315872b..0acaf3c13 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -31,12 +31,12 @@ paths: schema: type: string enum: - - cTime - - uTime + - createdAt + - updatedAt - followedAt - default: cTime + default: createdAt examples: - - cTime + - createdAt description: Field by which to order the results. - $ref: "#/components/parameters/SortOrder" - $ref: "#/components/parameters/PageIndex" @@ -103,7 +103,7 @@ paths: schema: $ref: "#/components/schemas/User" - /user/following/{username}: + /user/{username}/following: post: tags: - Users @@ -169,7 +169,7 @@ paths: type: object required: - name - - isPublic + - visibility properties: remixSource: oneOf: @@ -187,8 +187,8 @@ paths: File paths and their corresponding universal URLs associated with the project. **Required if `remixSource` is not provided.** - isPublic: - $ref: "#/components/schemas/Project/properties/isPublic" + visibility: + $ref: "#/components/schemas/Project/properties/visibility" description: $ref: "#/components/schemas/Project/properties/description" instructions: @@ -214,7 +214,10 @@ paths: in: query schema: $ref: "#/components/schemas/Project/properties/owner" - description: Filter projects by the owner's username. + description: | + Filter projects by the owner's username. + + Defaults to the authenticated user if not specified. Use `*` to include projects from all users. - name: remixedFrom in: query schema: @@ -227,15 +230,11 @@ paths: schema: $ref: "#/components/schemas/Project/properties/name" description: Filter projects by name pattern. - - name: isPublic + - name: visibility in: query schema: - $ref: "#/components/schemas/Project/properties/isPublic" - description: | - Filter projects by visibility status. - - - `0`: Personal - - `1`: Public + $ref: "#/components/schemas/Project/properties/visibility" + description: Filter projects by visibility. - name: liker in: query schema: @@ -270,15 +269,15 @@ paths: schema: type: string enum: - - cTime - - uTime + - createdAt + - updatedAt - likeCount - remixCount - recentLikeCount - recentRemixCount - default: cTime + default: createdAt examples: - - cTime + - createdAt description: Field by which to order the results. - $ref: "#/components/parameters/SortOrder" - $ref: "#/components/parameters/PageIndex" @@ -349,16 +348,13 @@ paths: schema: type: object required: - - name - files - - isPublic + - visibility properties: - name: - $ref: "#/components/schemas/Project/properties/name" files: $ref: "#/components/schemas/Project/properties/files" - isPublic: - $ref: "#/components/schemas/Project/properties/isPublic" + visibility: + $ref: "#/components/schemas/Project/properties/visibility" description: $ref: "#/components/schemas/Project/properties/description" instructions: @@ -394,7 +390,7 @@ paths: "204": description: Successfully deleted the project. - /project/liking/{owner}/{name}: + /project/{owner}/{name}/liking: post: tags: - Projects @@ -509,12 +505,12 @@ paths: schema: type: string enum: - - cTime - - uTime + - createdAt + - updatedAt - remixCount - default: cTime + default: createdAt examples: - - cTime + - createdAt description: Field by which to order the results. - $ref: "#/components/parameters/SortOrder" - $ref: "#/components/parameters/PageIndex" @@ -582,26 +578,24 @@ paths: type: object required: - displayName + - type - category - - assetType - files - filesHash - - isPublic + - visibility properties: displayName: $ref: "#/components/schemas/Asset/properties/displayName" + type: + $ref: "#/components/schemas/Asset/properties/type" category: $ref: "#/components/schemas/Asset/properties/category" - assetType: - $ref: "#/components/schemas/Asset/properties/assetType" files: $ref: "#/components/schemas/Asset/properties/files" filesHash: $ref: "#/components/schemas/Asset/properties/filesHash" - preview: - $ref: "#/components/schemas/Asset/properties/preview" - isPublic: - $ref: "#/components/schemas/Asset/properties/isPublic" + visibility: + $ref: "#/components/schemas/Asset/properties/visibility" responses: "201": description: Successfully created the asset. @@ -626,46 +620,39 @@ paths: in: query schema: $ref: "#/components/schemas/User/properties/username" - description: Filter assets by owner's username. + description: | + Filter assets by owner's username. + + Defaults to the authenticated user if not specified. Use `*` to include assets from all users. + - name: type + in: query + schema: + $ref: "#/components/schemas/Asset/properties/type" + description: Filter assets by type. - name: category in: query schema: $ref: "#/components/schemas/Asset/properties/category" description: Filter assets by category. - - name: assetType - in: query - schema: - $ref: "#/components/schemas/Asset/properties/assetType" - description: | - Filter assets by type. - - - `0`: Sprite - - `1`: Backdrop - - `2`: Sound - name: filesHash in: query schema: $ref: "#/components/schemas/Asset/properties/filesHash" description: Filter assets by files hash. - - name: isPublic + - name: visibility in: query schema: - $ref: "#/components/schemas/Asset/properties/isPublic" - description: | - Filter assets by visibility status. - - - `0`: Personal - - `1`: Public + $ref: "#/components/schemas/Asset/properties/visibility" + description: Filter assets by visibility. - name: orderBy in: query schema: type: string enum: - - default - - time - - clickCount + - createdAt + - updatedAt examples: - - default + - createdAt description: Field by which to order the results. - $ref: "#/components/parameters/SortOrder" - $ref: "#/components/parameters/PageIndex" @@ -726,26 +713,24 @@ paths: type: object required: - displayName + - type - category - - assetType - files - filesHash - - isPublic + - visibility properties: displayName: $ref: "#/components/schemas/Asset/properties/displayName" + type: + $ref: "#/components/schemas/Asset/properties/type" category: $ref: "#/components/schemas/Asset/properties/category" - assetType: - $ref: "#/components/schemas/Asset/properties/assetType" files: $ref: "#/components/schemas/Asset/properties/files" filesHash: $ref: "#/components/schemas/Asset/properties/filesHash" - preview: - $ref: "#/components/schemas/Asset/properties/preview" - isPublic: - $ref: "#/components/schemas/Asset/properties/isPublic" + visibility: + $ref: "#/components/schemas/Asset/properties/visibility" responses: "200": description: Successfully updated the asset. @@ -769,23 +754,6 @@ paths: "204": description: Successfully deleted the asset. - /asset/{id}/click: - post: - tags: - - Assets - summary: Increment the click count of an asset - description: Increase the click count of the specified asset. - parameters: - - name: id - in: path - required: true - schema: - $ref: "#/components/schemas/Model/properties/id" - description: ID of the asset for which to increase click count. - responses: - "204": - description: Successfully increased the click count. - /aigc/matting: post: tags: @@ -928,42 +896,37 @@ components: examples: - 1 description: Unique identifier. - cTime: + createdAt: type: string format: date-time examples: - 2006-01-02T15:04:05Z07:00 description: Creation timestamp. - uTime: + updatedAt: type: string format: date-time examples: - 2006-01-02T15:04:05Z07:00 description: Last update timestamp. - IsPublic: - type: integer - format: int8 + Visibility: + type: string enum: - - 0 - - 1 + - private + - public examples: - - 1 - description: | - Visibility status of the object. - - - `0`: Personal - - `1`: Public + - private + description: Visibility of the object. User: type: object properties: id: $ref: "#/components/schemas/Model/properties/id" - cTime: - $ref: "#/components/schemas/Model/properties/cTime" - uTime: - $ref: "#/components/schemas/Model/properties/uTime" + createdAt: + $ref: "#/components/schemas/Model/properties/createdAt" + updatedAt: + $ref: "#/components/schemas/Model/properties/updatedAt" username: type: string examples: @@ -1015,14 +978,15 @@ components: properties: id: $ref: "#/components/schemas/Model/properties/id" - cTime: - $ref: "#/components/schemas/Model/properties/cTime" - uTime: - $ref: "#/components/schemas/Model/properties/uTime" + createdAt: + $ref: "#/components/schemas/Model/properties/createdAt" + updatedAt: + $ref: "#/components/schemas/Model/properties/updatedAt" owner: $ref: "#/components/schemas/User/properties/username" remixedFrom: $ref: "#/components/schemas/ProjectReleaseFullName" + nullable: true description: Full name of the project release from which the project is remixed. name: type: string @@ -1045,8 +1009,8 @@ components: assets/sprites/Sprite/index.json: kodo://goplus-builder-usercontent-test/files/FoDx7DeLnvbXK45k23OU7IvzmlKE-215 main.spx: kodo://goplus-builder-usercontent-test/files/Fto5o-5ea0sNMlW_75VgGJCv2AcJ-0 description: File paths and their corresponding universal URLs associated with the project. - isPublic: - $ref: "#/components/schemas/IsPublic" + visibility: + $ref: "#/components/schemas/Visibility" description: type: string examples: @@ -1103,10 +1067,10 @@ components: properties: id: $ref: "#/components/schemas/Model/properties/id" - cTime: - $ref: "#/components/schemas/Model/properties/cTime" - uTime: - $ref: "#/components/schemas/Model/properties/uTime" + createdAt: + $ref: "#/components/schemas/Model/properties/createdAt" + updatedAt: + $ref: "#/components/schemas/Model/properties/updatedAt" projectFullName: $ref: "#/components/schemas/ProjectFullName" description: Full name of the project that the release is associated with, in the format `owner/project`. @@ -1142,10 +1106,10 @@ components: properties: id: $ref: "#/components/schemas/Model/properties/id" - cTime: - $ref: "#/components/schemas/Model/properties/cTime" - uTime: - $ref: "#/components/schemas/Model/properties/uTime" + createdAt: + $ref: "#/components/schemas/Model/properties/createdAt" + updatedAt: + $ref: "#/components/schemas/Model/properties/updatedAt" owner: $ref: "#/components/schemas/User/properties/username" description: Username of the asset's owner. @@ -1154,26 +1118,20 @@ components: examples: - NiuXiaoQi description: Display name of the asset. + type: + type: string + enum: + - sprite + - backdrop + - sound + examples: + - sprite + description: Type of the asset. category: type: string examples: - People description: Category to which the asset belongs. - assetType: - type: integer - format: int8 - enum: - - 0 - - 1 - - 2 - examples: - - 0 - description: | - Type of the asset. - - - `0`: Sprite - - `1`: Backdrop - - `2`: Sound files: type: object examples: @@ -1186,21 +1144,8 @@ components: examples: - h1:qUqP5cyxm6YcTAhz05Hph5gvu9M= description: Hash of the asset files. - preview: - type: string - format: uri - examples: - - https://builder.goplus.org/asset_preview.png - description: URL of the asset's preview image. - isPublic: - $ref: "#/components/schemas/IsPublic" - clickCount: - type: integer - format: int64 - minimum: 0 - examples: - - 1 - description: Number of times the asset has been clicked. + visibility: + $ref: "#/components/schemas/Visibility" UpInfo: type: object diff --git a/spx-backend/.env.dev b/spx-backend/.env.dev index 9e6f2c2d5..a53aadc94 100644 --- a/spx-backend/.env.dev +++ b/spx-backend/.env.dev @@ -45,4 +45,4 @@ ZNIZlCk3CrqUzVnAln07K9++5egCisZ0XADLp1cgCZk0NRM7a2LuMDx/iZXw1QaQ Iw== -----END CERTIFICATE-----" GOP_CASDOOR_ORGANIZATIONNAME="GoPlus" -GOP_CASDOOR_APPLICATONNAME="application_x8aevk" +GOP_CASDOOR_APPLICATIONNAME="application_x8aevk" diff --git a/spx-backend/cmd/spx-backend/delete_asset_#id.yap b/spx-backend/cmd/spx-backend/delete_asset_#id.yap index 055e17275..774aeb0f5 100644 --- a/spx-backend/cmd/spx-backend/delete_asset_#id.yap +++ b/spx-backend/cmd/spx-backend/delete_asset_#id.yap @@ -9,4 +9,4 @@ if err := ctrl.DeleteAsset(ctx.Context(), ${id}); err != nil { replyWithInnerError(ctx, err) return } -json nil +text 204, "", "" diff --git a/spx-backend/cmd/spx-backend/delete_project_#owner_#name.yap b/spx-backend/cmd/spx-backend/delete_project_#owner_#name.yap index e5c41569c..9aea956c7 100644 --- a/spx-backend/cmd/spx-backend/delete_project_#owner_#name.yap +++ b/spx-backend/cmd/spx-backend/delete_project_#owner_#name.yap @@ -9,4 +9,4 @@ if err := ctrl.DeleteProject(ctx.Context(), ${owner}, ${name}); err != nil { replyWithInnerError(ctx, err) return } -json nil +text 204, "", "" diff --git a/spx-backend/cmd/spx-backend/delete_project_#owner_#name_liking.yap b/spx-backend/cmd/spx-backend/delete_project_#owner_#name_liking.yap new file mode 100644 index 000000000..7e15ba1bf --- /dev/null +++ b/spx-backend/cmd/spx-backend/delete_project_#owner_#name_liking.yap @@ -0,0 +1,16 @@ +// Unlike a project. +// +// Request: +// DELETE /project/:owner/:name/liking + +ctx := &Context + +if _, ok := ensureUser(ctx); !ok { + return +} + +if err := ctrl.UnlikeProject(ctx.Context(), ${owner}, ${name}); err != nil { + replyWithInnerError(ctx, err) + return +} +text 204, "", "" diff --git a/spx-backend/cmd/spx-backend/delete_user_#username_following.yap b/spx-backend/cmd/spx-backend/delete_user_#username_following.yap new file mode 100644 index 000000000..842c06e93 --- /dev/null +++ b/spx-backend/cmd/spx-backend/delete_user_#username_following.yap @@ -0,0 +1,16 @@ +// Unfollow a user. +// +// Request: +// DELETE /user/:username/following + +ctx := &Context + +if _, ok := ensureUser(ctx); !ok { + return +} + +if err := ctrl.UnfollowUser(ctx.Context(), ${username}); err != nil { + replyWithInnerError(ctx, err) + return +} +text 204, "", "" diff --git a/spx-backend/cmd/spx-backend/get_assets_list.yap b/spx-backend/cmd/spx-backend/get_assets_list.yap index 4b67d4cd7..e305b104d 100644 --- a/spx-backend/cmd/spx-backend/get_assets_list.yap +++ b/spx-backend/cmd/spx-backend/get_assets_list.yap @@ -4,18 +4,17 @@ // GET /assets/list import ( - "strconv" - "github.com/goplus/builder/spx-backend/internal/controller" - "github.com/goplus/builder/spx-backend/internal/model" ) ctx := &Context user, _ := controller.UserFromContext(ctx.Context()) -params := &controller.ListAssetsParams{} +params := controller.NewListAssetsParams() -params.Keyword = ${keyword} +if keyword := ${keyword}; keyword != "" { + params.Keyword = &keyword +} switch owner := ${owner}; owner { case "": @@ -23,39 +22,27 @@ case "": replyWithCode(ctx, errorUnauthorized) return } - params.Owner = &user.Name + params.Owner = &user.Username case "*": params.Owner = nil default: params.Owner = &owner } -if category := ${category}; category != "" { - params.Category = &category +if typeParam := ctx.Param("type"); typeParam != "" { + params.Type = &typeParam } -if assetTypeParam := ${assetType}; assetTypeParam != "" { - assetTypeInt, err := strconv.Atoi(assetTypeParam) - if err != nil { - replyWithCode(ctx, errorInvalidArgs) - return - } - assetType := model.AssetType(assetTypeInt) - params.AssetType = &assetType +if category := ${category}; category != "" { + params.Category = &category } if filesHash := ${filesHash}; filesHash != "" { params.FilesHash = &filesHash } -if isPublicParam := ${isPublic}; isPublicParam != "" { - isPublicInt, err := strconv.Atoi(isPublicParam) - if err != nil { - replyWithCode(ctx, errorInvalidArgs) - return - } - isPublic := model.IsPublic(isPublicInt) - params.IsPublic = &isPublic +if visibility := ${visibility}; visibility != "" { + params.Visibility = &visibility } if orderBy := ${orderBy}; orderBy != "" { diff --git a/spx-backend/cmd/spx-backend/get_project-release_#owner_#project_#release.yap b/spx-backend/cmd/spx-backend/get_project-release_#owner_#project_#release.yap new file mode 100644 index 000000000..19e773810 --- /dev/null +++ b/spx-backend/cmd/spx-backend/get_project-release_#owner_#project_#release.yap @@ -0,0 +1,13 @@ +// Get project release by its full name. +// +// Request: +// GET /project-release/:owner/:project/:release + +ctx := &Context + +projectRelease, err := ctrl.GetProjectRelease(ctx.Context(), ${owner}, ${project}, ${release}) +if err != nil { + replyWithInnerError(ctx, err) + return +} +json projectRelease diff --git a/spx-backend/cmd/spx-backend/get_project-releases_list.yap b/spx-backend/cmd/spx-backend/get_project-releases_list.yap new file mode 100644 index 000000000..e5c19d870 --- /dev/null +++ b/spx-backend/cmd/spx-backend/get_project-releases_list.yap @@ -0,0 +1,34 @@ +// List project releases. +// +// Request: +// GET /project-releases/list + +import ( + "github.com/goplus/builder/spx-backend/internal/controller" +) + +ctx := &Context + +params := controller.NewListProjectReleasesParams() + +if projectFullName := ${projectFullName}; projectFullName != "" { + params.ProjectFullName = &projectFullName +} + +if orderBy := ${orderBy}; orderBy != "" { + params.OrderBy = controller.ListProjectReleasesOrderBy(orderBy) +} + +params.Pagination.Index = ctx.ParamInt("pageIndex", firstPageIndex) +params.Pagination.Size = ctx.ParamInt("pageSize", defaultPageSize) +if ok, msg := params.Validate(); !ok { + replyWithCodeMsg(ctx, errorInvalidArgs, msg) + return +} + +projectReleases, err := ctrl.ListProjectReleases(ctx.Context(), params) +if err != nil { + replyWithInnerError(ctx, err) + return +} +json projectReleases diff --git a/spx-backend/cmd/spx-backend/get_project_#owner_#name_liking.yap b/spx-backend/cmd/spx-backend/get_project_#owner_#name_liking.yap new file mode 100644 index 000000000..ca3b24041 --- /dev/null +++ b/spx-backend/cmd/spx-backend/get_project_#owner_#name_liking.yap @@ -0,0 +1,21 @@ +// Check if a project is liked by the authenticated user. +// +// Request: +// GET /project/:owner/:name/liking + +ctx := &Context + +if _, ok := ensureUser(ctx); !ok { + return +} + +hasLiked, err := ctrl.HasLikedProject(ctx.Context(), ${owner}, ${name}) +if err != nil { + replyWithInnerError(ctx, err) + return +} +if hasLiked { + ctx.text 204, "", "" +} else { + replyWithCode(ctx, errorNotFound) +} diff --git a/spx-backend/cmd/spx-backend/get_projects_list.yap b/spx-backend/cmd/spx-backend/get_projects_list.yap index 16ee50813..19859ee2d 100644 --- a/spx-backend/cmd/spx-backend/get_projects_list.yap +++ b/spx-backend/cmd/spx-backend/get_projects_list.yap @@ -5,25 +5,15 @@ import ( "strconv" + "time" "github.com/goplus/builder/spx-backend/internal/controller" - "github.com/goplus/builder/spx-backend/internal/model" ) ctx := &Context user, _ := controller.UserFromContext(ctx.Context()) -params := &controller.ListProjectsParams{} - -if isPublicParam := ${isPublic}; isPublicParam != "" { - isPublicInt, err := strconv.Atoi(isPublicParam) - if err != nil { - replyWithCode(ctx, errorInvalidArgs) - return - } - isPublic := model.IsPublic(isPublicInt) - params.IsPublic = &isPublic -} +params := controller.NewListProjectsParams() switch owner := ${owner}; owner { case "": @@ -31,13 +21,69 @@ case "": replyWithCode(ctx, errorUnauthorized) return } - params.Owner = &user.Name + params.Owner = &user.Username case "*": params.Owner = nil default: params.Owner = &owner } +if remixedFrom := ${remixedFrom}; remixedFrom != "" { + params.RemixedFrom = &remixedFrom +} + +if keyword := ${keyword}; keyword != "" { + params.Keyword = &keyword +} + +if visibility := ${visibility}; visibility != "" { + params.Visibility = &visibility +} + +if liker := ${liker}; liker != "" { + params.Liker = &liker +} + +if createdAfter := ${createdAfter}; createdAfter != "" { + createdAfterTime, err := time.Parse(time.RFC3339Nano, createdAfter) + if err != nil { + replyWithCodeMsg(ctx, errorInvalidArgs, "invalid createdAfter") + return + } + params.CreatedAfter = &createdAfterTime +} + +if likesReceivedAfter := ${likesReceivedAfter}; likesReceivedAfter != "" { + likesReceivedAfterTime, err := time.Parse(time.RFC3339Nano, likesReceivedAfter) + if err != nil { + replyWithCodeMsg(ctx, errorInvalidArgs, "invalid likesReceivedAfter") + return + } + params.LikesReceivedAfter = &likesReceivedAfterTime +} + +if remixesReceivedAfter := ${remixesReceivedAfter}; remixesReceivedAfter != "" { + remixesReceivedAfterTime, err := time.Parse(time.RFC3339Nano, remixesReceivedAfter) + if err != nil { + replyWithCodeMsg(ctx, errorInvalidArgs, "invalid remixesReceivedAfter") + return + } + params.RemixesReceivedAfter = &remixesReceivedAfterTime +} + +if fromFollowees := ${fromFollowees}; fromFollowees != "" { + fromFolloweesBool, err := strconv.ParseBool(fromFollowees) + if err != nil { + replyWithCodeMsg(ctx, errorInvalidArgs, "invalid fromFollowees") + return + } + params.FromFollowees = &fromFolloweesBool +} + +if orderBy := ${orderBy}; orderBy != "" { + params.OrderBy = controller.ListProjectsOrderBy(orderBy) +} + params.Pagination.Index = paramInt("pageIndex", firstPageIndex) params.Pagination.Size = paramInt("pageSize", defaultPageSize) if ok, msg := params.Validate(); !ok { diff --git a/spx-backend/cmd/spx-backend/get_user_#username.yap b/spx-backend/cmd/spx-backend/get_user_#username.yap new file mode 100644 index 000000000..78dd79e89 --- /dev/null +++ b/spx-backend/cmd/spx-backend/get_user_#username.yap @@ -0,0 +1,13 @@ +// Get user by username. +// +// Request: +// GET /user/:username + +ctx := &Context + +user, err := ctrl.GetUser(ctx.Context(), ${username}) +if err != nil { + replyWithInnerError(ctx, err) + return +} +json user diff --git a/spx-backend/cmd/spx-backend/get_user_#username_following.yap b/spx-backend/cmd/spx-backend/get_user_#username_following.yap new file mode 100644 index 000000000..2ef5a61f0 --- /dev/null +++ b/spx-backend/cmd/spx-backend/get_user_#username_following.yap @@ -0,0 +1,21 @@ +// Check if a user is followed by the authenticated user. +// +// Request: +// GET /user/:username/following + +ctx := &Context + +if _, ok := ensureUser(ctx); !ok { + return +} + +isFollowing, err := ctrl.IsFollowingUser(ctx.Context(), ${username}) +if err != nil { + replyWithInnerError(ctx, err) + return +} +if isFollowing { + ctx.text 204, "", "" +} else { + replyWithCode(ctx, errorNotFound) +} diff --git a/spx-backend/cmd/spx-backend/get_users_list.yap b/spx-backend/cmd/spx-backend/get_users_list.yap new file mode 100644 index 000000000..a3258385d --- /dev/null +++ b/spx-backend/cmd/spx-backend/get_users_list.yap @@ -0,0 +1,38 @@ +// List users. +// +// Request: +// GET /users/list + +import ( + "github.com/goplus/builder/spx-backend/internal/controller" +) + +ctx := &Context + +params := controller.NewListUsersParams() + +if follower := ${follower}; follower != "" { + params.Follower = &follower +} + +if followee := ${followee}; followee != "" { + params.Followee = &followee +} + +if orderBy := ${orderBy}; orderBy != "" { + params.OrderBy = controller.ListUsersOrderBy(orderBy) +} + +params.Pagination.Index = ctx.ParamInt("pageIndex", firstPageIndex) +params.Pagination.Size = ctx.ParamInt("pageSize", defaultPageSize) +if ok, msg := params.Validate(); !ok { + replyWithCodeMsg(ctx, errorInvalidArgs, msg) + return +} + +users, err := ctrl.ListUsers(ctx.Context(), params) +if err != nil { + replyWithInnerError(ctx, err) + return +} +json users diff --git a/spx-backend/cmd/spx-backend/gop_autogen.go b/spx-backend/cmd/spx-backend/gop_autogen.go index 138c17bef..ed8f378e1 100644 --- a/spx-backend/cmd/spx-backend/gop_autogen.go +++ b/spx-backend/cmd/spx-backend/gop_autogen.go @@ -7,7 +7,6 @@ import ( "errors" "github.com/goplus/builder/spx-backend/internal/controller" "github.com/goplus/builder/spx-backend/internal/log" - "github.com/goplus/builder/spx-backend/internal/model" "github.com/goplus/yap" "net/http" "os" @@ -31,6 +30,14 @@ type delete_project_owner_name struct { yap.Handler *AppV2 } +type delete_project_owner_name_liking struct { + yap.Handler + *AppV2 +} +type delete_user_username_following struct { + yap.Handler + *AppV2 +} type get_asset_id struct { yap.Handler *AppV2 @@ -39,14 +46,38 @@ type get_assets_list struct { yap.Handler *AppV2 } +type get_project_release_owner_project_release struct { + yap.Handler + *AppV2 +} +type get_project_releases_list struct { + yap.Handler + *AppV2 +} type get_project_owner_name struct { yap.Handler *AppV2 } +type get_project_owner_name_liking struct { + yap.Handler + *AppV2 +} type get_projects_list struct { yap.Handler *AppV2 } +type get_user_username struct { + yap.Handler + *AppV2 +} +type get_user_username_following struct { + yap.Handler + *AppV2 +} +type get_users_list struct { + yap.Handler + *AppV2 +} type get_util_upinfo struct { yap.Handler *AppV2 @@ -64,7 +95,7 @@ type post_asset struct { yap.Handler *AppV2 } -type post_asset_id_click struct { +type post_project_release struct { yap.Handler *AppV2 } @@ -72,6 +103,14 @@ type post_project struct { yap.Handler *AppV2 } +type post_project_owner_name_liking struct { + yap.Handler + *AppV2 +} +type post_user_username_following struct { + yap.Handler + *AppV2 +} type post_util_fileurls struct { yap.Handler *AppV2 @@ -88,6 +127,10 @@ type put_project_owner_name struct { yap.Handler *AppV2 } +type put_user struct { + yap.Handler + *AppV2 +} //line cmd/spx-backend/main.yap:26 func (this *AppV2) MainEntry() { //line cmd/spx-backend/main.yap:26:1 @@ -145,7 +188,7 @@ func (this *AppV2) MainEntry() { } } func (this *AppV2) Main() { - yap.Gopt_AppV2_Main(this, new(delete_asset_id), new(delete_project_owner_name), new(get_asset_id), new(get_assets_list), new(get_project_owner_name), new(get_projects_list), new(get_util_upinfo), new(post_aigc_matting), new(post_asset), new(post_asset_id_click), new(post_project), new(post_util_fileurls), new(post_util_fmtcode), new(put_asset_id), new(put_project_owner_name)) + yap.Gopt_AppV2_Main(this, new(delete_asset_id), new(delete_project_owner_name), new(delete_project_owner_name_liking), new(delete_user_username_following), new(get_asset_id), new(get_assets_list), new(get_project_release_owner_project_release), new(get_project_releases_list), new(get_project_owner_name), new(get_project_owner_name_liking), new(get_projects_list), new(get_user_username), new(get_user_username_following), new(get_users_list), new(get_util_upinfo), new(post_aigc_matting), new(post_asset), new(post_project_release), new(post_project), new(post_project_owner_name_liking), new(post_user_username_following), new(post_util_fileurls), new(post_util_fmtcode), new(put_asset_id), new(put_project_owner_name), new(put_user)) } //line cmd/spx-backend/delete_asset_#id.yap:6 func (this *delete_asset_id) Main(_gop_arg0 *yap.Context) { @@ -162,7 +205,7 @@ func (this *delete_asset_id) Main(_gop_arg0 *yap.Context) { return } //line cmd/spx-backend/delete_asset_#id.yap:12:1 - this.Json__1(nil) + this.Text__0(204, "", "") } func (this *delete_asset_id) Classfname() string { return "delete_asset_#id" @@ -182,11 +225,65 @@ func (this *delete_project_owner_name) Main(_gop_arg0 *yap.Context) { return } //line cmd/spx-backend/delete_project_#owner_#name.yap:12:1 - this.Json__1(nil) + this.Text__0(204, "", "") } func (this *delete_project_owner_name) Classfname() string { return "delete_project_#owner_#name" } +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:6 +func (this *delete_project_owner_name_liking) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:6:1 + ctx := &this.Context +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:8:1 + if +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:8:1 + _, ok := ensureUser(ctx); !ok { +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:9:1 + return + } +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:12:1 + if +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:12:1 + err := this.ctrl.UnlikeProject(ctx.Context(), this.Gop_Env("owner"), this.Gop_Env("name")); err != nil { +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:13:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:14:1 + return + } +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:16:1 + this.Text__0(204, "", "") +} +func (this *delete_project_owner_name_liking) Classfname() string { + return "delete_project_#owner_#name_liking" +} +//line cmd/spx-backend/delete_user_#username_following.yap:6 +func (this *delete_user_username_following) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/delete_user_#username_following.yap:6:1 + ctx := &this.Context +//line cmd/spx-backend/delete_user_#username_following.yap:8:1 + if +//line cmd/spx-backend/delete_user_#username_following.yap:8:1 + _, ok := ensureUser(ctx); !ok { +//line cmd/spx-backend/delete_user_#username_following.yap:9:1 + return + } +//line cmd/spx-backend/delete_user_#username_following.yap:12:1 + if +//line cmd/spx-backend/delete_user_#username_following.yap:12:1 + err := this.ctrl.UnfollowUser(ctx.Context(), this.Gop_Env("username")); err != nil { +//line cmd/spx-backend/delete_user_#username_following.yap:13:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/delete_user_#username_following.yap:14:1 + return + } +//line cmd/spx-backend/delete_user_#username_following.yap:16:1 + this.Text__0(204, "", "") +} +func (this *delete_user_username_following) Classfname() string { + return "delete_user_#username_following" +} //line cmd/spx-backend/get_asset_#id.yap:6 func (this *get_asset_id) Main(_gop_arg0 *yap.Context) { this.Handler.Main(_gop_arg0) @@ -207,125 +304,177 @@ func (this *get_asset_id) Main(_gop_arg0 *yap.Context) { func (this *get_asset_id) Classfname() string { return "get_asset_#id" } -//line cmd/spx-backend/get_assets_list.yap:13 +//line cmd/spx-backend/get_assets_list.yap:10 func (this *get_assets_list) Main(_gop_arg0 *yap.Context) { this.Handler.Main(_gop_arg0) -//line cmd/spx-backend/get_assets_list.yap:13:1 +//line cmd/spx-backend/get_assets_list.yap:10:1 ctx := &this.Context -//line cmd/spx-backend/get_assets_list.yap:15:1 +//line cmd/spx-backend/get_assets_list.yap:12:1 user, _ := controller.UserFromContext(ctx.Context()) +//line cmd/spx-backend/get_assets_list.yap:13:1 + params := controller.NewListAssetsParams() +//line cmd/spx-backend/get_assets_list.yap:15:1 + if +//line cmd/spx-backend/get_assets_list.yap:15:1 + keyword := this.Gop_Env("keyword"); keyword != "" { //line cmd/spx-backend/get_assets_list.yap:16:1 - params := &controller.ListAssetsParams{} -//line cmd/spx-backend/get_assets_list.yap:18:1 - params.Keyword = this.Gop_Env("keyword") -//line cmd/spx-backend/get_assets_list.yap:20:1 + params.Keyword = &keyword + } +//line cmd/spx-backend/get_assets_list.yap:19:1 switch -//line cmd/spx-backend/get_assets_list.yap:20:1 +//line cmd/spx-backend/get_assets_list.yap:19:1 owner := this.Gop_Env("owner"); owner { -//line cmd/spx-backend/get_assets_list.yap:21:1 +//line cmd/spx-backend/get_assets_list.yap:20:1 case "": -//line cmd/spx-backend/get_assets_list.yap:22:1 +//line cmd/spx-backend/get_assets_list.yap:21:1 if user == nil { -//line cmd/spx-backend/get_assets_list.yap:23:1 +//line cmd/spx-backend/get_assets_list.yap:22:1 replyWithCode(ctx, errorUnauthorized) -//line cmd/spx-backend/get_assets_list.yap:24:1 +//line cmd/spx-backend/get_assets_list.yap:23:1 return } +//line cmd/spx-backend/get_assets_list.yap:25:1 + params.Owner = &user.Username //line cmd/spx-backend/get_assets_list.yap:26:1 - params.Owner = &user.Name -//line cmd/spx-backend/get_assets_list.yap:27:1 case "*": -//line cmd/spx-backend/get_assets_list.yap:28:1 +//line cmd/spx-backend/get_assets_list.yap:27:1 params.Owner = nil -//line cmd/spx-backend/get_assets_list.yap:29:1 +//line cmd/spx-backend/get_assets_list.yap:28:1 default: -//line cmd/spx-backend/get_assets_list.yap:30:1 +//line cmd/spx-backend/get_assets_list.yap:29:1 params.Owner = &owner } -//line cmd/spx-backend/get_assets_list.yap:33:1 +//line cmd/spx-backend/get_assets_list.yap:32:1 if +//line cmd/spx-backend/get_assets_list.yap:32:1 + typeParam := ctx.Param("type"); typeParam != "" { //line cmd/spx-backend/get_assets_list.yap:33:1 + params.Type = &typeParam + } +//line cmd/spx-backend/get_assets_list.yap:36:1 + if +//line cmd/spx-backend/get_assets_list.yap:36:1 category := this.Gop_Env("category"); category != "" { -//line cmd/spx-backend/get_assets_list.yap:34:1 +//line cmd/spx-backend/get_assets_list.yap:37:1 params.Category = &category } -//line cmd/spx-backend/get_assets_list.yap:37:1 +//line cmd/spx-backend/get_assets_list.yap:40:1 if -//line cmd/spx-backend/get_assets_list.yap:37:1 - assetTypeParam := this.Gop_Env("assetType"); assetTypeParam != "" { -//line cmd/spx-backend/get_assets_list.yap:38:1 - assetTypeInt, err := strconv.Atoi(assetTypeParam) -//line cmd/spx-backend/get_assets_list.yap:39:1 - if err != nil { //line cmd/spx-backend/get_assets_list.yap:40:1 - replyWithCode(ctx, errorInvalidArgs) + filesHash := this.Gop_Env("filesHash"); filesHash != "" { //line cmd/spx-backend/get_assets_list.yap:41:1 - return - } -//line cmd/spx-backend/get_assets_list.yap:43:1 - assetType := model.AssetType(assetTypeInt) + params.FilesHash = &filesHash + } +//line cmd/spx-backend/get_assets_list.yap:44:1 + if //line cmd/spx-backend/get_assets_list.yap:44:1 - params.AssetType = &assetType + visibility := this.Gop_Env("visibility"); visibility != "" { +//line cmd/spx-backend/get_assets_list.yap:45:1 + params.Visibility = &visibility } -//line cmd/spx-backend/get_assets_list.yap:47:1 +//line cmd/spx-backend/get_assets_list.yap:48:1 if -//line cmd/spx-backend/get_assets_list.yap:47:1 - filesHash := this.Gop_Env("filesHash"); filesHash != "" { //line cmd/spx-backend/get_assets_list.yap:48:1 - params.FilesHash = &filesHash + orderBy := this.Gop_Env("orderBy"); orderBy != "" { +//line cmd/spx-backend/get_assets_list.yap:49:1 + params.OrderBy = controller.ListAssetsOrderBy(orderBy) } -//line cmd/spx-backend/get_assets_list.yap:51:1 - if -//line cmd/spx-backend/get_assets_list.yap:51:1 - isPublicParam := this.Gop_Env("isPublic"); isPublicParam != "" { //line cmd/spx-backend/get_assets_list.yap:52:1 - isPublicInt, err := strconv.Atoi(isPublicParam) + params.Pagination.Index = ctx.ParamInt("pageIndex", firstPageIndex) //line cmd/spx-backend/get_assets_list.yap:53:1 - if err != nil { + params.Pagination.Size = ctx.ParamInt("pageSize", defaultPageSize) //line cmd/spx-backend/get_assets_list.yap:54:1 - replyWithCode(ctx, errorInvalidArgs) + if +//line cmd/spx-backend/get_assets_list.yap:54:1 + ok, msg := params.Validate(); !ok { //line cmd/spx-backend/get_assets_list.yap:55:1 - return - } -//line cmd/spx-backend/get_assets_list.yap:57:1 - isPublic := model.IsPublic(isPublicInt) -//line cmd/spx-backend/get_assets_list.yap:58:1 - params.IsPublic = &isPublic + replyWithCodeMsg(ctx, errorInvalidArgs, msg) +//line cmd/spx-backend/get_assets_list.yap:56:1 + return } +//line cmd/spx-backend/get_assets_list.yap:59:1 + assets, err := this.ctrl.ListAssets(ctx.Context(), params) +//line cmd/spx-backend/get_assets_list.yap:60:1 + if err != nil { //line cmd/spx-backend/get_assets_list.yap:61:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/get_assets_list.yap:62:1 + return + } +//line cmd/spx-backend/get_assets_list.yap:64:1 + this.Json__1(assets) +} +func (this *get_assets_list) Classfname() string { + return "get_assets_list" +} +//line cmd/spx-backend/get_project-release_#owner_#project_#release.yap:6 +func (this *get_project_release_owner_project_release) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/get_project-release_#owner_#project_#release.yap:6:1 + ctx := &this.Context +//line cmd/spx-backend/get_project-release_#owner_#project_#release.yap:8:1 + projectRelease, err := this.ctrl.GetProjectRelease(ctx.Context(), this.Gop_Env("owner"), this.Gop_Env("project"), this.Gop_Env("release")) +//line cmd/spx-backend/get_project-release_#owner_#project_#release.yap:9:1 + if err != nil { +//line cmd/spx-backend/get_project-release_#owner_#project_#release.yap:10:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/get_project-release_#owner_#project_#release.yap:11:1 + return + } +//line cmd/spx-backend/get_project-release_#owner_#project_#release.yap:13:1 + this.Json__1(projectRelease) +} +func (this *get_project_release_owner_project_release) Classfname() string { + return "get_project-release_#owner_#project_#release" +} +//line cmd/spx-backend/get_project-releases_list.yap:10 +func (this *get_project_releases_list) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/get_project-releases_list.yap:10:1 + ctx := &this.Context +//line cmd/spx-backend/get_project-releases_list.yap:12:1 + params := controller.NewListProjectReleasesParams() +//line cmd/spx-backend/get_project-releases_list.yap:14:1 if -//line cmd/spx-backend/get_assets_list.yap:61:1 +//line cmd/spx-backend/get_project-releases_list.yap:14:1 + projectFullName := this.Gop_Env("projectFullName"); projectFullName != "" { +//line cmd/spx-backend/get_project-releases_list.yap:15:1 + params.ProjectFullName = &projectFullName + } +//line cmd/spx-backend/get_project-releases_list.yap:18:1 + if +//line cmd/spx-backend/get_project-releases_list.yap:18:1 orderBy := this.Gop_Env("orderBy"); orderBy != "" { -//line cmd/spx-backend/get_assets_list.yap:62:1 - params.OrderBy = controller.ListAssetsOrderBy(orderBy) +//line cmd/spx-backend/get_project-releases_list.yap:19:1 + params.OrderBy = controller.ListProjectReleasesOrderBy(orderBy) } -//line cmd/spx-backend/get_assets_list.yap:65:1 +//line cmd/spx-backend/get_project-releases_list.yap:22:1 params.Pagination.Index = ctx.ParamInt("pageIndex", firstPageIndex) -//line cmd/spx-backend/get_assets_list.yap:66:1 +//line cmd/spx-backend/get_project-releases_list.yap:23:1 params.Pagination.Size = ctx.ParamInt("pageSize", defaultPageSize) -//line cmd/spx-backend/get_assets_list.yap:67:1 +//line cmd/spx-backend/get_project-releases_list.yap:24:1 if -//line cmd/spx-backend/get_assets_list.yap:67:1 +//line cmd/spx-backend/get_project-releases_list.yap:24:1 ok, msg := params.Validate(); !ok { -//line cmd/spx-backend/get_assets_list.yap:68:1 +//line cmd/spx-backend/get_project-releases_list.yap:25:1 replyWithCodeMsg(ctx, errorInvalidArgs, msg) -//line cmd/spx-backend/get_assets_list.yap:69:1 +//line cmd/spx-backend/get_project-releases_list.yap:26:1 return } -//line cmd/spx-backend/get_assets_list.yap:72:1 - assets, err := this.ctrl.ListAssets(ctx.Context(), params) -//line cmd/spx-backend/get_assets_list.yap:73:1 +//line cmd/spx-backend/get_project-releases_list.yap:29:1 + projectReleases, err := this.ctrl.ListProjectReleases(ctx.Context(), params) +//line cmd/spx-backend/get_project-releases_list.yap:30:1 if err != nil { -//line cmd/spx-backend/get_assets_list.yap:74:1 +//line cmd/spx-backend/get_project-releases_list.yap:31:1 replyWithInnerError(ctx, err) -//line cmd/spx-backend/get_assets_list.yap:75:1 +//line cmd/spx-backend/get_project-releases_list.yap:32:1 return } -//line cmd/spx-backend/get_assets_list.yap:77:1 - this.Json__1(assets) +//line cmd/spx-backend/get_project-releases_list.yap:34:1 + this.Json__1(projectReleases) } -func (this *get_assets_list) Classfname() string { - return "get_assets_list" +func (this *get_project_releases_list) Classfname() string { + return "get_project-releases_list" } //line cmd/spx-backend/get_project_#owner_#name.yap:6 func (this *get_project_owner_name) Main(_gop_arg0 *yap.Context) { @@ -347,6 +496,39 @@ func (this *get_project_owner_name) Main(_gop_arg0 *yap.Context) { func (this *get_project_owner_name) Classfname() string { return "get_project_#owner_#name" } +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:6 +func (this *get_project_owner_name_liking) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:6:1 + ctx := &this.Context +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:8:1 + if +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:8:1 + _, ok := ensureUser(ctx); !ok { +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:9:1 + return + } +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:12:1 + hasLiked, err := this.ctrl.HasLikedProject(ctx.Context(), this.Gop_Env("owner"), this.Gop_Env("name")) +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:13:1 + if err != nil { +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:14:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:15:1 + return + } +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:17:1 + if hasLiked { +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:18:1 + ctx.Text__0(204, "", "") + } else { +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:20:1 + replyWithCode(ctx, errorNotFound) + } +} +func (this *get_project_owner_name_liking) Classfname() string { + return "get_project_#owner_#name_liking" +} //line cmd/spx-backend/get_projects_list.yap:13 func (this *get_projects_list) Main(_gop_arg0 *yap.Context) { this.Handler.Main(_gop_arg0) @@ -355,77 +537,267 @@ func (this *get_projects_list) Main(_gop_arg0 *yap.Context) { //line cmd/spx-backend/get_projects_list.yap:15:1 user, _ := controller.UserFromContext(ctx.Context()) //line cmd/spx-backend/get_projects_list.yap:16:1 - params := &controller.ListProjectsParams{} + params := controller.NewListProjectsParams() //line cmd/spx-backend/get_projects_list.yap:18:1 - if + switch //line cmd/spx-backend/get_projects_list.yap:18:1 - isPublicParam := this.Gop_Env("isPublic"); isPublicParam != "" { + owner := this.Gop_Env("owner"); owner { //line cmd/spx-backend/get_projects_list.yap:19:1 - isPublicInt, err := strconv.Atoi(isPublicParam) + case "": //line cmd/spx-backend/get_projects_list.yap:20:1 - if err != nil { + if user == nil { //line cmd/spx-backend/get_projects_list.yap:21:1 - replyWithCode(ctx, errorInvalidArgs) + replyWithCode(ctx, errorUnauthorized) //line cmd/spx-backend/get_projects_list.yap:22:1 return } //line cmd/spx-backend/get_projects_list.yap:24:1 - isPublic := model.IsPublic(isPublicInt) + params.Owner = &user.Username //line cmd/spx-backend/get_projects_list.yap:25:1 - params.IsPublic = &isPublic - } -//line cmd/spx-backend/get_projects_list.yap:28:1 - switch + case "*": +//line cmd/spx-backend/get_projects_list.yap:26:1 + params.Owner = nil +//line cmd/spx-backend/get_projects_list.yap:27:1 + default: //line cmd/spx-backend/get_projects_list.yap:28:1 - owner := this.Gop_Env("owner"); owner { -//line cmd/spx-backend/get_projects_list.yap:29:1 - case "": -//line cmd/spx-backend/get_projects_list.yap:30:1 - if user == nil { + params.Owner = &owner + } //line cmd/spx-backend/get_projects_list.yap:31:1 - replyWithCode(ctx, errorUnauthorized) + if +//line cmd/spx-backend/get_projects_list.yap:31:1 + remixedFrom := this.Gop_Env("remixedFrom"); remixedFrom != "" { //line cmd/spx-backend/get_projects_list.yap:32:1 - return - } -//line cmd/spx-backend/get_projects_list.yap:34:1 - params.Owner = &user.Name + params.RemixedFrom = &remixedFrom + } //line cmd/spx-backend/get_projects_list.yap:35:1 - case "*": + if +//line cmd/spx-backend/get_projects_list.yap:35:1 + keyword := this.Gop_Env("keyword"); keyword != "" { //line cmd/spx-backend/get_projects_list.yap:36:1 - params.Owner = nil -//line cmd/spx-backend/get_projects_list.yap:37:1 - default: -//line cmd/spx-backend/get_projects_list.yap:38:1 - params.Owner = &owner + params.Keyword = &keyword + } +//line cmd/spx-backend/get_projects_list.yap:39:1 + if +//line cmd/spx-backend/get_projects_list.yap:39:1 + visibility := this.Gop_Env("visibility"); visibility != "" { +//line cmd/spx-backend/get_projects_list.yap:40:1 + params.Visibility = &visibility } -//line cmd/spx-backend/get_projects_list.yap:41:1 - params.Pagination.Index = this.ParamInt("pageIndex", firstPageIndex) -//line cmd/spx-backend/get_projects_list.yap:42:1 - params.Pagination.Size = this.ParamInt("pageSize", defaultPageSize) //line cmd/spx-backend/get_projects_list.yap:43:1 if //line cmd/spx-backend/get_projects_list.yap:43:1 - ok, msg := params.Validate(); !ok { + liker := this.Gop_Env("liker"); liker != "" { //line cmd/spx-backend/get_projects_list.yap:44:1 + params.Liker = &liker + } +//line cmd/spx-backend/get_projects_list.yap:47:1 + if +//line cmd/spx-backend/get_projects_list.yap:47:1 + createdAfter := this.Gop_Env("createdAfter"); createdAfter != "" { +//line cmd/spx-backend/get_projects_list.yap:48:1 + createdAfterTime, err := time.Parse(time.RFC3339Nano, createdAfter) +//line cmd/spx-backend/get_projects_list.yap:49:1 + if err != nil { +//line cmd/spx-backend/get_projects_list.yap:50:1 + replyWithCodeMsg(ctx, errorInvalidArgs, "invalid createdAfter") +//line cmd/spx-backend/get_projects_list.yap:51:1 + return + } +//line cmd/spx-backend/get_projects_list.yap:53:1 + params.CreatedAfter = &createdAfterTime + } +//line cmd/spx-backend/get_projects_list.yap:56:1 + if +//line cmd/spx-backend/get_projects_list.yap:56:1 + likesReceivedAfter := this.Gop_Env("likesReceivedAfter"); likesReceivedAfter != "" { +//line cmd/spx-backend/get_projects_list.yap:57:1 + likesReceivedAfterTime, err := time.Parse(time.RFC3339Nano, likesReceivedAfter) +//line cmd/spx-backend/get_projects_list.yap:58:1 + if err != nil { +//line cmd/spx-backend/get_projects_list.yap:59:1 + replyWithCodeMsg(ctx, errorInvalidArgs, "invalid likesReceivedAfter") +//line cmd/spx-backend/get_projects_list.yap:60:1 + return + } +//line cmd/spx-backend/get_projects_list.yap:62:1 + params.LikesReceivedAfter = &likesReceivedAfterTime + } +//line cmd/spx-backend/get_projects_list.yap:65:1 + if +//line cmd/spx-backend/get_projects_list.yap:65:1 + remixesReceivedAfter := this.Gop_Env("remixesReceivedAfter"); remixesReceivedAfter != "" { +//line cmd/spx-backend/get_projects_list.yap:66:1 + remixesReceivedAfterTime, err := time.Parse(time.RFC3339Nano, remixesReceivedAfter) +//line cmd/spx-backend/get_projects_list.yap:67:1 + if err != nil { +//line cmd/spx-backend/get_projects_list.yap:68:1 + replyWithCodeMsg(ctx, errorInvalidArgs, "invalid remixesReceivedAfter") +//line cmd/spx-backend/get_projects_list.yap:69:1 + return + } +//line cmd/spx-backend/get_projects_list.yap:71:1 + params.RemixesReceivedAfter = &remixesReceivedAfterTime + } +//line cmd/spx-backend/get_projects_list.yap:74:1 + if +//line cmd/spx-backend/get_projects_list.yap:74:1 + fromFollowees := this.Gop_Env("fromFollowees"); fromFollowees != "" { +//line cmd/spx-backend/get_projects_list.yap:75:1 + fromFolloweesBool, err := strconv.ParseBool(fromFollowees) +//line cmd/spx-backend/get_projects_list.yap:76:1 + if err != nil { +//line cmd/spx-backend/get_projects_list.yap:77:1 + replyWithCodeMsg(ctx, errorInvalidArgs, "invalid fromFollowees") +//line cmd/spx-backend/get_projects_list.yap:78:1 + return + } +//line cmd/spx-backend/get_projects_list.yap:80:1 + params.FromFollowees = &fromFolloweesBool + } +//line cmd/spx-backend/get_projects_list.yap:83:1 + if +//line cmd/spx-backend/get_projects_list.yap:83:1 + orderBy := this.Gop_Env("orderBy"); orderBy != "" { +//line cmd/spx-backend/get_projects_list.yap:84:1 + params.OrderBy = controller.ListProjectsOrderBy(orderBy) + } +//line cmd/spx-backend/get_projects_list.yap:87:1 + params.Pagination.Index = this.ParamInt("pageIndex", firstPageIndex) +//line cmd/spx-backend/get_projects_list.yap:88:1 + params.Pagination.Size = this.ParamInt("pageSize", defaultPageSize) +//line cmd/spx-backend/get_projects_list.yap:89:1 + if +//line cmd/spx-backend/get_projects_list.yap:89:1 + ok, msg := params.Validate(); !ok { +//line cmd/spx-backend/get_projects_list.yap:90:1 replyWithCodeMsg(ctx, errorInvalidArgs, msg) -//line cmd/spx-backend/get_projects_list.yap:45:1 +//line cmd/spx-backend/get_projects_list.yap:91:1 return } -//line cmd/spx-backend/get_projects_list.yap:48:1 +//line cmd/spx-backend/get_projects_list.yap:94:1 projects, err := this.ctrl.ListProjects(ctx.Context(), params) -//line cmd/spx-backend/get_projects_list.yap:49:1 +//line cmd/spx-backend/get_projects_list.yap:95:1 if err != nil { -//line cmd/spx-backend/get_projects_list.yap:50:1 +//line cmd/spx-backend/get_projects_list.yap:96:1 replyWithInnerError(ctx, err) -//line cmd/spx-backend/get_projects_list.yap:51:1 +//line cmd/spx-backend/get_projects_list.yap:97:1 return } -//line cmd/spx-backend/get_projects_list.yap:53:1 +//line cmd/spx-backend/get_projects_list.yap:99:1 this.Json__1(projects) } func (this *get_projects_list) Classfname() string { return "get_projects_list" } +//line cmd/spx-backend/get_user_#username.yap:6 +func (this *get_user_username) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/get_user_#username.yap:6:1 + ctx := &this.Context +//line cmd/spx-backend/get_user_#username.yap:8:1 + user, err := this.ctrl.GetUser(ctx.Context(), this.Gop_Env("username")) +//line cmd/spx-backend/get_user_#username.yap:9:1 + if err != nil { +//line cmd/spx-backend/get_user_#username.yap:10:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/get_user_#username.yap:11:1 + return + } +//line cmd/spx-backend/get_user_#username.yap:13:1 + this.Json__1(user) +} +func (this *get_user_username) Classfname() string { + return "get_user_#username" +} +//line cmd/spx-backend/get_user_#username_following.yap:6 +func (this *get_user_username_following) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/get_user_#username_following.yap:6:1 + ctx := &this.Context +//line cmd/spx-backend/get_user_#username_following.yap:8:1 + if +//line cmd/spx-backend/get_user_#username_following.yap:8:1 + _, ok := ensureUser(ctx); !ok { +//line cmd/spx-backend/get_user_#username_following.yap:9:1 + return + } +//line cmd/spx-backend/get_user_#username_following.yap:12:1 + isFollowing, err := this.ctrl.IsFollowingUser(ctx.Context(), this.Gop_Env("username")) +//line cmd/spx-backend/get_user_#username_following.yap:13:1 + if err != nil { +//line cmd/spx-backend/get_user_#username_following.yap:14:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/get_user_#username_following.yap:15:1 + return + } +//line cmd/spx-backend/get_user_#username_following.yap:17:1 + if isFollowing { +//line cmd/spx-backend/get_user_#username_following.yap:18:1 + ctx.Text__0(204, "", "") + } else { +//line cmd/spx-backend/get_user_#username_following.yap:20:1 + replyWithCode(ctx, errorNotFound) + } +} +func (this *get_user_username_following) Classfname() string { + return "get_user_#username_following" +} +//line cmd/spx-backend/get_users_list.yap:10 +func (this *get_users_list) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/get_users_list.yap:10:1 + ctx := &this.Context +//line cmd/spx-backend/get_users_list.yap:12:1 + params := controller.NewListUsersParams() +//line cmd/spx-backend/get_users_list.yap:14:1 + if +//line cmd/spx-backend/get_users_list.yap:14:1 + follower := this.Gop_Env("follower"); follower != "" { +//line cmd/spx-backend/get_users_list.yap:15:1 + params.Follower = &follower + } +//line cmd/spx-backend/get_users_list.yap:18:1 + if +//line cmd/spx-backend/get_users_list.yap:18:1 + followee := this.Gop_Env("followee"); followee != "" { +//line cmd/spx-backend/get_users_list.yap:19:1 + params.Followee = &followee + } +//line cmd/spx-backend/get_users_list.yap:22:1 + if +//line cmd/spx-backend/get_users_list.yap:22:1 + orderBy := this.Gop_Env("orderBy"); orderBy != "" { +//line cmd/spx-backend/get_users_list.yap:23:1 + params.OrderBy = controller.ListUsersOrderBy(orderBy) + } +//line cmd/spx-backend/get_users_list.yap:26:1 + params.Pagination.Index = ctx.ParamInt("pageIndex", firstPageIndex) +//line cmd/spx-backend/get_users_list.yap:27:1 + params.Pagination.Size = ctx.ParamInt("pageSize", defaultPageSize) +//line cmd/spx-backend/get_users_list.yap:28:1 + if +//line cmd/spx-backend/get_users_list.yap:28:1 + ok, msg := params.Validate(); !ok { +//line cmd/spx-backend/get_users_list.yap:29:1 + replyWithCodeMsg(ctx, errorInvalidArgs, msg) +//line cmd/spx-backend/get_users_list.yap:30:1 + return + } +//line cmd/spx-backend/get_users_list.yap:33:1 + users, err := this.ctrl.ListUsers(ctx.Context(), params) +//line cmd/spx-backend/get_users_list.yap:34:1 + if err != nil { +//line cmd/spx-backend/get_users_list.yap:35:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/get_users_list.yap:36:1 + return + } +//line cmd/spx-backend/get_users_list.yap:38:1 + this.Json__1(users) +} +func (this *get_users_list) Classfname() string { + return "get_users_list" +} //line cmd/spx-backend/get_util_upinfo.yap:6 func (this *get_util_upinfo) Main(_gop_arg0 *yap.Context) { this.Handler.Main(_gop_arg0) @@ -502,64 +874,85 @@ func (this *post_asset) Main(_gop_arg0 *yap.Context) { //line cmd/spx-backend/post_asset.yap:10:1 ctx := &this.Context //line cmd/spx-backend/post_asset.yap:12:1 - user, ok := ensureUser(ctx) + if +//line cmd/spx-backend/post_asset.yap:12:1 + _, ok := ensureUser(ctx); !ok { //line cmd/spx-backend/post_asset.yap:13:1 - if !ok { -//line cmd/spx-backend/post_asset.yap:14:1 return } +//line cmd/spx-backend/post_asset.yap:16:1 + params := &controller.CreateAssetParams{} //line cmd/spx-backend/post_asset.yap:17:1 - params := &controller.AddAssetParams{} -//line cmd/spx-backend/post_asset.yap:18:1 if !parseJSON(ctx, params) { -//line cmd/spx-backend/post_asset.yap:19:1 +//line cmd/spx-backend/post_asset.yap:18:1 return } -//line cmd/spx-backend/post_asset.yap:21:1 - params.Owner = user.Name -//line cmd/spx-backend/post_asset.yap:22:1 +//line cmd/spx-backend/post_asset.yap:20:1 if -//line cmd/spx-backend/post_asset.yap:22:1 +//line cmd/spx-backend/post_asset.yap:20:1 ok, msg := params.Validate(); !ok { -//line cmd/spx-backend/post_asset.yap:23:1 +//line cmd/spx-backend/post_asset.yap:21:1 replyWithCodeMsg(ctx, errorInvalidArgs, msg) -//line cmd/spx-backend/post_asset.yap:24:1 +//line cmd/spx-backend/post_asset.yap:22:1 return } -//line cmd/spx-backend/post_asset.yap:27:1 - asset, err := this.ctrl.AddAsset(ctx.Context(), params) -//line cmd/spx-backend/post_asset.yap:28:1 +//line cmd/spx-backend/post_asset.yap:25:1 + asset, err := this.ctrl.CreateAsset(ctx.Context(), params) +//line cmd/spx-backend/post_asset.yap:26:1 if err != nil { -//line cmd/spx-backend/post_asset.yap:29:1 +//line cmd/spx-backend/post_asset.yap:27:1 replyWithInnerError(ctx, err) -//line cmd/spx-backend/post_asset.yap:30:1 +//line cmd/spx-backend/post_asset.yap:28:1 return } -//line cmd/spx-backend/post_asset.yap:32:1 - this.Json__1(asset) +//line cmd/spx-backend/post_asset.yap:30:1 + this.Json__0(201, asset) } func (this *post_asset) Classfname() string { return "post_asset" } -//line cmd/spx-backend/post_asset_#id_click.yap:6 -func (this *post_asset_id_click) Main(_gop_arg0 *yap.Context) { +//line cmd/spx-backend/post_project-release.yap:10 +func (this *post_project_release) Main(_gop_arg0 *yap.Context) { this.Handler.Main(_gop_arg0) -//line cmd/spx-backend/post_asset_#id_click.yap:6:1 +//line cmd/spx-backend/post_project-release.yap:10:1 ctx := &this.Context -//line cmd/spx-backend/post_asset_#id_click.yap:8:1 +//line cmd/spx-backend/post_project-release.yap:12:1 + if +//line cmd/spx-backend/post_project-release.yap:12:1 + _, ok := ensureUser(ctx); !ok { +//line cmd/spx-backend/post_project-release.yap:13:1 + return + } +//line cmd/spx-backend/post_project-release.yap:16:1 + params := &controller.CreateProjectReleaseParams{} +//line cmd/spx-backend/post_project-release.yap:17:1 + if !parseJSON(ctx, params) { +//line cmd/spx-backend/post_project-release.yap:18:1 + return + } +//line cmd/spx-backend/post_project-release.yap:20:1 if -//line cmd/spx-backend/post_asset_#id_click.yap:8:1 - err := this.ctrl.IncreaseAssetClickCount(ctx.Context(), this.Gop_Env("id")); err != nil { -//line cmd/spx-backend/post_asset_#id_click.yap:9:1 +//line cmd/spx-backend/post_project-release.yap:20:1 + ok, msg := params.Validate(); !ok { +//line cmd/spx-backend/post_project-release.yap:21:1 + replyWithCodeMsg(ctx, errorInvalidArgs, msg) +//line cmd/spx-backend/post_project-release.yap:22:1 + return + } +//line cmd/spx-backend/post_project-release.yap:25:1 + projectRelease, err := this.ctrl.CreateProjectRelease(ctx.Context(), params) +//line cmd/spx-backend/post_project-release.yap:26:1 + if err != nil { +//line cmd/spx-backend/post_project-release.yap:27:1 replyWithInnerError(ctx, err) -//line cmd/spx-backend/post_asset_#id_click.yap:10:1 +//line cmd/spx-backend/post_project-release.yap:28:1 return } -//line cmd/spx-backend/post_asset_#id_click.yap:12:1 - this.Json__1(nil) +//line cmd/spx-backend/post_project-release.yap:30:1 + this.Json__0(201, projectRelease) } -func (this *post_asset_id_click) Classfname() string { - return "post_asset_#id_click" +func (this *post_project_release) Classfname() string { + return "post_project-release" } //line cmd/spx-backend/post_project.yap:10 func (this *post_project) Main(_gop_arg0 *yap.Context) { @@ -567,45 +960,97 @@ func (this *post_project) Main(_gop_arg0 *yap.Context) { //line cmd/spx-backend/post_project.yap:10:1 ctx := &this.Context //line cmd/spx-backend/post_project.yap:12:1 - user, ok := ensureUser(ctx) + if +//line cmd/spx-backend/post_project.yap:12:1 + _, ok := ensureUser(ctx); !ok { //line cmd/spx-backend/post_project.yap:13:1 - if !ok { -//line cmd/spx-backend/post_project.yap:14:1 return } +//line cmd/spx-backend/post_project.yap:16:1 + params := &controller.CreateProjectParams{} //line cmd/spx-backend/post_project.yap:17:1 - params := &controller.AddProjectParams{} -//line cmd/spx-backend/post_project.yap:18:1 if !parseJSON(ctx, params) { -//line cmd/spx-backend/post_project.yap:19:1 +//line cmd/spx-backend/post_project.yap:18:1 return } -//line cmd/spx-backend/post_project.yap:21:1 - params.Owner = user.Name -//line cmd/spx-backend/post_project.yap:22:1 +//line cmd/spx-backend/post_project.yap:20:1 if -//line cmd/spx-backend/post_project.yap:22:1 +//line cmd/spx-backend/post_project.yap:20:1 ok, msg := params.Validate(); !ok { -//line cmd/spx-backend/post_project.yap:23:1 +//line cmd/spx-backend/post_project.yap:21:1 replyWithCodeMsg(ctx, errorInvalidArgs, msg) -//line cmd/spx-backend/post_project.yap:24:1 +//line cmd/spx-backend/post_project.yap:22:1 return } -//line cmd/spx-backend/post_project.yap:27:1 - project, err := this.ctrl.AddProject(ctx.Context(), params) -//line cmd/spx-backend/post_project.yap:28:1 +//line cmd/spx-backend/post_project.yap:25:1 + project, err := this.ctrl.CreateProject(ctx.Context(), params) +//line cmd/spx-backend/post_project.yap:26:1 if err != nil { -//line cmd/spx-backend/post_project.yap:29:1 +//line cmd/spx-backend/post_project.yap:27:1 replyWithInnerError(ctx, err) -//line cmd/spx-backend/post_project.yap:30:1 +//line cmd/spx-backend/post_project.yap:28:1 return } -//line cmd/spx-backend/post_project.yap:32:1 - this.Json__1(project) +//line cmd/spx-backend/post_project.yap:30:1 + this.Json__0(201, project) } func (this *post_project) Classfname() string { return "post_project" } +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:6 +func (this *post_project_owner_name_liking) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:6:1 + ctx := &this.Context +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:8:1 + if +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:8:1 + _, ok := ensureUser(ctx); !ok { +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:9:1 + return + } +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:12:1 + if +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:12:1 + err := this.ctrl.LikeProject(ctx.Context(), this.Gop_Env("owner"), this.Gop_Env("name")); err != nil { +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:13:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:14:1 + return + } +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:16:1 + this.Text__0(204, "", "") +} +func (this *post_project_owner_name_liking) Classfname() string { + return "post_project_#owner_#name_liking" +} +//line cmd/spx-backend/post_user_#username_following.yap:6 +func (this *post_user_username_following) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/post_user_#username_following.yap:6:1 + ctx := &this.Context +//line cmd/spx-backend/post_user_#username_following.yap:8:1 + if +//line cmd/spx-backend/post_user_#username_following.yap:8:1 + _, ok := ensureUser(ctx); !ok { +//line cmd/spx-backend/post_user_#username_following.yap:9:1 + return + } +//line cmd/spx-backend/post_user_#username_following.yap:12:1 + if +//line cmd/spx-backend/post_user_#username_following.yap:12:1 + err := this.ctrl.FollowUser(ctx.Context(), this.Gop_Env("username")); err != nil { +//line cmd/spx-backend/post_user_#username_following.yap:13:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/post_user_#username_following.yap:14:1 + return + } +//line cmd/spx-backend/post_user_#username_following.yap:16:1 + this.Text__0(204, "", "") +} +func (this *post_user_username_following) Classfname() string { + return "post_user_#username_following" +} //line cmd/spx-backend/post_util_fileurls.yap:10 func (this *post_util_fileurls) Main(_gop_arg0 *yap.Context) { this.Handler.Main(_gop_arg0) @@ -637,7 +1082,7 @@ func (this *post_util_fileurls) Main(_gop_arg0 *yap.Context) { return } //line cmd/spx-backend/post_util_fileurls.yap:26:1 - this.Json__1(fileURLs) + this.Json__0(201, fileURLs) } func (this *post_util_fileurls) Classfname() string { return "post_util_fileurls" @@ -673,7 +1118,7 @@ func (this *post_util_fmtcode) Main(_gop_arg0 *yap.Context) { return } //line cmd/spx-backend/post_util_fmtcode.yap:26:1 - this.Json__1(formattedCode) + this.Json__0(201, formattedCode) } func (this *post_util_fmtcode) Classfname() string { return "post_util_fmtcode" @@ -764,6 +1209,49 @@ func (this *put_project_owner_name) Main(_gop_arg0 *yap.Context) { func (this *put_project_owner_name) Classfname() string { return "put_project_#owner_#name" } +//line cmd/spx-backend/put_user.yap:10 +func (this *put_user) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/put_user.yap:10:1 + ctx := &this.Context +//line cmd/spx-backend/put_user.yap:12:1 + if +//line cmd/spx-backend/put_user.yap:12:1 + _, ok := ensureUser(ctx); !ok { +//line cmd/spx-backend/put_user.yap:13:1 + return + } +//line cmd/spx-backend/put_user.yap:16:1 + params := &controller.UpdateAuthedUserParams{} +//line cmd/spx-backend/put_user.yap:17:1 + if !parseJSON(ctx, params) { +//line cmd/spx-backend/put_user.yap:18:1 + return + } +//line cmd/spx-backend/put_user.yap:20:1 + if +//line cmd/spx-backend/put_user.yap:20:1 + ok, msg := params.Validate(); !ok { +//line cmd/spx-backend/put_user.yap:21:1 + replyWithCodeMsg(ctx, errorInvalidArgs, msg) +//line cmd/spx-backend/put_user.yap:22:1 + return + } +//line cmd/spx-backend/put_user.yap:25:1 + user, err := this.ctrl.UpdateAuthedUser(ctx.Context(), params) +//line cmd/spx-backend/put_user.yap:26:1 + if err != nil { +//line cmd/spx-backend/put_user.yap:27:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/put_user.yap:28:1 + return + } +//line cmd/spx-backend/put_user.yap:30:1 + this.Json__1(user) +} +func (this *put_user) Classfname() string { + return "put_user" +} func main() { new(AppV2).Main() } diff --git a/spx-backend/cmd/spx-backend/middleware.go b/spx-backend/cmd/spx-backend/middleware.go index 03fcfa00b..96723c6fd 100644 --- a/spx-backend/cmd/spx-backend/middleware.go +++ b/spx-backend/cmd/spx-backend/middleware.go @@ -53,7 +53,7 @@ func NewUserMiddleware(ctrl *controller.Controller) func(next http.Handler) http authorization := r.Header.Get("Authorization") if authorization != "" { token := strings.TrimPrefix(authorization, "Bearer ") - user, err := ctrl.UserFromToken(token) + user, err := ctrl.UserFromToken(r.Context(), token) if err != nil { logger.Printf("failed to get user from token: %v", err) } else if user == nil { diff --git a/spx-backend/cmd/spx-backend/post_asset.yap b/spx-backend/cmd/spx-backend/post_asset.yap index de860ada5..7869bb616 100644 --- a/spx-backend/cmd/spx-backend/post_asset.yap +++ b/spx-backend/cmd/spx-backend/post_asset.yap @@ -1,4 +1,4 @@ -// Add an asset. +// Create an asset. // // Request: // POST /asset @@ -9,24 +9,22 @@ import ( ctx := &Context -user, ok := ensureUser(ctx) -if !ok { +if _, ok := ensureUser(ctx); !ok { return } -params := &controller.AddAssetParams{} +params := &controller.CreateAssetParams{} if !parseJSON(ctx, params) { return } -params.Owner = user.Name if ok, msg := params.Validate(); !ok { replyWithCodeMsg(ctx, errorInvalidArgs, msg) return } -asset, err := ctrl.AddAsset(ctx.Context(), params) +asset, err := ctrl.CreateAsset(ctx.Context(), params) if err != nil { replyWithInnerError(ctx, err) return } -json asset +json 201, asset diff --git a/spx-backend/cmd/spx-backend/post_asset_#id_click.yap b/spx-backend/cmd/spx-backend/post_asset_#id_click.yap deleted file mode 100644 index 5b6d15f5b..000000000 --- a/spx-backend/cmd/spx-backend/post_asset_#id_click.yap +++ /dev/null @@ -1,12 +0,0 @@ -// Increase the click count of an asset. -// -// Request: -// POST /asset/:id/click - -ctx := &Context - -if err := ctrl.IncreaseAssetClickCount(ctx.Context(), ${id}); err != nil { - replyWithInnerError(ctx, err) - return -} -json nil diff --git a/spx-backend/cmd/spx-backend/post_project-release.yap b/spx-backend/cmd/spx-backend/post_project-release.yap new file mode 100644 index 000000000..f412e260c --- /dev/null +++ b/spx-backend/cmd/spx-backend/post_project-release.yap @@ -0,0 +1,30 @@ +// Create a project release. +// +// Request: +// POST /project-release + +import ( + "github.com/goplus/builder/spx-backend/internal/controller" +) + +ctx := &Context + +if _, ok := ensureUser(ctx); !ok { + return +} + +params := &controller.CreateProjectReleaseParams{} +if !parseJSON(ctx, params) { + return +} +if ok, msg := params.Validate(); !ok { + replyWithCodeMsg(ctx, errorInvalidArgs, msg) + return +} + +projectRelease, err := ctrl.CreateProjectRelease(ctx.Context(), params) +if err != nil { + replyWithInnerError(ctx, err) + return +} +json 201, projectRelease diff --git a/spx-backend/cmd/spx-backend/post_project.yap b/spx-backend/cmd/spx-backend/post_project.yap index 5d9e269b4..bc5dd4a70 100644 --- a/spx-backend/cmd/spx-backend/post_project.yap +++ b/spx-backend/cmd/spx-backend/post_project.yap @@ -1,4 +1,4 @@ -// Add a project. +// Create a project. // // Request: // POST /project @@ -9,24 +9,22 @@ import ( ctx := &Context -user, ok := ensureUser(ctx) -if !ok { +if _, ok := ensureUser(ctx); !ok { return } -params := &controller.AddProjectParams{} +params := &controller.CreateProjectParams{} if !parseJSON(ctx, params) { return } -params.Owner = user.Name if ok, msg := params.Validate(); !ok { replyWithCodeMsg(ctx, errorInvalidArgs, msg) return } -project, err := ctrl.AddProject(ctx.Context(), params) +project, err := ctrl.CreateProject(ctx.Context(), params) if err != nil { replyWithInnerError(ctx, err) return } -json project +json 201, project diff --git a/spx-backend/cmd/spx-backend/post_project_#owner_#name_liking.yap b/spx-backend/cmd/spx-backend/post_project_#owner_#name_liking.yap new file mode 100644 index 000000000..54e54f3ff --- /dev/null +++ b/spx-backend/cmd/spx-backend/post_project_#owner_#name_liking.yap @@ -0,0 +1,16 @@ +// Like a project. +// +// Request: +// POST /project/:owner/:name/liking + +ctx := &Context + +if _, ok := ensureUser(ctx); !ok { + return +} + +if err := ctrl.LikeProject(ctx.Context(), ${owner}, ${name}); err != nil { + replyWithInnerError(ctx, err) + return +} +text 204, "", "" diff --git a/spx-backend/cmd/spx-backend/post_user_#username_following.yap b/spx-backend/cmd/spx-backend/post_user_#username_following.yap new file mode 100644 index 000000000..19ee2e03d --- /dev/null +++ b/spx-backend/cmd/spx-backend/post_user_#username_following.yap @@ -0,0 +1,16 @@ +// Follow a user. +// +// Request: +// POST /user/:username/following + +ctx := &Context + +if _, ok := ensureUser(ctx); !ok { + return +} + +if err := ctrl.FollowUser(ctx.Context(), ${username}); err != nil { + replyWithInnerError(ctx, err) + return +} +text 204, "", "" diff --git a/spx-backend/cmd/spx-backend/post_util_fileurls.yap b/spx-backend/cmd/spx-backend/post_util_fileurls.yap index faa9203c2..6c0d555b0 100644 --- a/spx-backend/cmd/spx-backend/post_util_fileurls.yap +++ b/spx-backend/cmd/spx-backend/post_util_fileurls.yap @@ -23,4 +23,4 @@ if err != nil { replyWithInnerError(ctx, err) return } -json fileURLs +json 201, fileURLs diff --git a/spx-backend/cmd/spx-backend/post_util_fmtcode.yap b/spx-backend/cmd/spx-backend/post_util_fmtcode.yap index 89e2dd924..42d7b63ec 100644 --- a/spx-backend/cmd/spx-backend/post_util_fmtcode.yap +++ b/spx-backend/cmd/spx-backend/post_util_fmtcode.yap @@ -23,4 +23,4 @@ if err != nil { replyWithInnerError(ctx, err) return } -json formattedCode +json 201, formattedCode diff --git a/spx-backend/cmd/spx-backend/put_user.yap b/spx-backend/cmd/spx-backend/put_user.yap new file mode 100644 index 000000000..87fb7211c --- /dev/null +++ b/spx-backend/cmd/spx-backend/put_user.yap @@ -0,0 +1,30 @@ +// Update the authenticated user. +// +// Request: +// PUT /user + +import ( + "github.com/goplus/builder/spx-backend/internal/controller" +) + +ctx := &Context + +if _, ok := ensureUser(ctx); !ok { + return +} + +params := &controller.UpdateAuthedUserParams{} +if !parseJSON(ctx, params) { + return +} +if ok, msg := params.Validate(); !ok { + replyWithCodeMsg(ctx, errorInvalidArgs, msg) + return +} + +user, err := ctrl.UpdateAuthedUser(ctx.Context(), params) +if err != nil { + replyWithInnerError(ctx, err) + return +} +json user diff --git a/spx-backend/cmd/spx-backend/util.go b/spx-backend/cmd/spx-backend/util.go index 6dee9899d..8132b0c2e 100644 --- a/spx-backend/cmd/spx-backend/util.go +++ b/spx-backend/cmd/spx-backend/util.go @@ -6,12 +6,14 @@ import ( "io" "github.com/goplus/builder/spx-backend/internal/controller" + "github.com/goplus/builder/spx-backend/internal/log" "github.com/goplus/builder/spx-backend/internal/model" "github.com/goplus/yap" + "gorm.io/gorm" ) // ensureUser ensures the user is authenticated. -func ensureUser(ctx *yap.Context) (u *controller.User, ok bool) { +func ensureUser(ctx *yap.Context) (u *model.User, ok bool) { u, ok = controller.UserFromContext(ctx.Context()) if !ok { replyWithCode(ctx, errorUnauthorized) @@ -54,15 +56,17 @@ func replyWithCodeMsg(ctx *yap.Context, code errorCode, msg string) { // replyWithInnerError replies to the client with the inner error. func replyWithInnerError(ctx *yap.Context, err error) { switch { - case errors.Is(err, model.ErrExist): + case errors.Is(err, gorm.ErrDuplicatedKey): replyWithCode(ctx, errorInvalidArgs) case errors.Is(err, controller.ErrUnauthorized): replyWithCode(ctx, errorUnauthorized) case errors.Is(err, controller.ErrForbidden): replyWithCode(ctx, errorForbidden) - case errors.Is(err, controller.ErrNotExist), errors.Is(err, model.ErrNotExist): + case errors.Is(err, controller.ErrNotExist), errors.Is(err, gorm.ErrRecordNotFound): replyWithCode(ctx, errorNotFound) default: + logger := log.GetReqLogger(ctx.Context()) + logger.Printf("failed to handle request [%s %s]: %v", ctx.Method, ctx.URL, err) replyWithCode(ctx, errorUnknown) } } diff --git a/spx-backend/go.mod b/spx-backend/go.mod index d8c541c9b..4970c923a 100644 --- a/spx-backend/go.mod +++ b/spx-backend/go.mod @@ -21,6 +21,8 @@ require ( github.com/goplus/gop v1.2.6 github.com/qiniu/go-sdk/v7 v7.18.0 github.com/stretchr/testify v1.8.1 + gorm.io/driver/mysql v1.5.7 + gorm.io/gorm v1.25.12 ) require ( @@ -31,6 +33,8 @@ require ( github.com/golang/protobuf v1.5.3 // indirect github.com/googleapis/gax-go/v2 v2.12.0 // indirect github.com/goplus/gogen v1.15.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect go.opencensus.io v0.24.0 // indirect golang.org/x/net v0.22.0 // indirect diff --git a/spx-backend/go.sum b/spx-backend/go.sum index f8bb644a6..ad1ab0fb8 100644 --- a/spx-backend/go.sum +++ b/spx-backend/go.sum @@ -75,6 +75,7 @@ github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= github.com/go-playground/validator/v10 v10.8.0/go.mod h1:9JhgTzTaE31GZDpH/HSvHiRJrJ3iKAgqqH0Bl/Ocjdk= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= @@ -127,6 +128,10 @@ github.com/goplus/gop v1.2.6 h1:kog3c5Js+8EopqmI4+CwueXsqibnBwYVt5q5N7juRVY= github.com/goplus/gop v1.2.6/go.mod h1:uREWbR1MrFaviZ4Mbx4ZCcAYDoqzO0iv1Qo6Np0Xx4E= github.com/goplus/yap v0.8.2-0.20240602010842-f547c8e81317 h1:/zflZoZaow6d/LO7mEXX0b1WYzQsixeJ7yKn2dSKAB0= github.com/goplus/yap v0.8.2-0.20240602010842-f547c8e81317/go.mod h1:kuBnII1/HeQLFPhnIKbBItTHA/gKogjPSymrvYqvG2g= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -291,5 +296,10 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/spx-backend/init.sql b/spx-backend/init.sql deleted file mode 100644 index e6d97874b..000000000 --- a/spx-backend/init.sql +++ /dev/null @@ -1,42 +0,0 @@ -SET NAMES utf8mb4; -SET FOREIGN_KEY_CHECKS = 0; - --- ---------------------------- --- Table structure for asset --- ---------------------------- -DROP TABLE IF EXISTS `asset`; -CREATE TABLE `asset` ( - `id` int NOT NULL AUTO_INCREMENT, - `c_time` datetime NULL DEFAULT NULL, - `u_time` datetime NULL DEFAULT NULL, - `display_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, - `owner` varchar(255) NULL DEFAULT NULL, - `category` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, - `asset_type` int NULL DEFAULT NULL, - `files` json NULL, - `files_hash` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, - `preview` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL, - `click_count` int NULL DEFAULT 0, - `is_public` tinyint NULL DEFAULT NULL, - `status` int NULL DEFAULT NULL, - PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for project --- ---------------------------- -DROP TABLE IF EXISTS `project`; -CREATE TABLE `project` ( - `id` int UNSIGNED NOT NULL AUTO_INCREMENT, - `c_time` datetime NULL DEFAULT NULL, - `u_time` datetime NULL DEFAULT NULL, - `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, - `owner` varchar(255) NULL DEFAULT NULL, - `version` int NOT NULL DEFAULT 1, - `files` json NULL, - `is_public` tinyint NULL DEFAULT NULL, - `status` int NULL DEFAULT NULL, - PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = DYNAMIC; - -SET FOREIGN_KEY_CHECKS = 1; diff --git a/spx-backend/internal/controller/asset.go b/spx-backend/internal/controller/asset.go index 510f09146..a8583c82d 100644 --- a/spx-backend/internal/controller/asset.go +++ b/spx-backend/internal/controller/asset.go @@ -2,202 +2,294 @@ package controller import ( "context" + "fmt" + "maps" "regexp" + "strconv" - "github.com/goplus/builder/spx-backend/internal/log" "github.com/goplus/builder/spx-backend/internal/model" ) +// AssetDTO is the DTO for assets. +type AssetDTO struct { + ModelDTO + + Owner string `json:"owner"` + DisplayName string `json:"displayName"` + Type string `json:"type"` + Category string `json:"category"` + Files model.FileCollection `json:"files"` + FilesHash string `json:"filesHash"` + Visibility string `json:"visibility"` +} + +// toAssetDTO converts the model asset to its DTO. +func toAssetDTO(mAsset model.Asset) AssetDTO { + return AssetDTO{ + ModelDTO: toModelDTO(mAsset.Model), + Owner: mAsset.Owner.Username, + DisplayName: mAsset.DisplayName, + Type: mAsset.Type.String(), + Category: mAsset.Category, + Files: mAsset.Files, + FilesHash: mAsset.FilesHash, + Visibility: mAsset.Visibility.String(), + } +} + // assetDisplayNameRE is the regular expression for asset display name. var assetDisplayNameRE = regexp.MustCompile(`^.{1,100}$`) // ensureAsset ensures the asset exists and the user has access to it. -func (ctrl *Controller) ensureAsset(ctx context.Context, id string, ownedOnly bool) (*model.Asset, error) { - logger := log.GetReqLogger(ctx) - - asset, err := model.AssetByID(ctx, ctrl.db, id) - if err != nil { - logger.Printf("failed to get asset: %v", err) - return nil, err +func (ctrl *Controller) ensureAsset(ctx context.Context, id int64, ownedOnly bool) (*model.Asset, error) { + var mAsset model.Asset + if err := ctrl.db.WithContext(ctx). + Preload("Owner"). + Where("id = ?", id). + First(&mAsset). + Error; err != nil { + return nil, fmt.Errorf("failed to get asset: %w", err) } - if ownedOnly || asset.IsPublic == model.Personal { - if _, err := EnsureUser(ctx, asset.Owner); err != nil { + if ownedOnly || mAsset.Visibility == model.VisibilityPrivate { + if _, err := ensureUser(ctx, mAsset.OwnerID); err != nil { return nil, err } } - return asset, nil + return &mAsset, nil } -// GetAsset gets asset by id. -func (ctrl *Controller) GetAsset(ctx context.Context, id string) (*model.Asset, error) { - return ctrl.ensureAsset(ctx, id, false) +// CreateAssetParams holds parameters for creating an asset. +type CreateAssetParams struct { + DisplayName string `json:"displayName"` + Type string `json:"type"` + Category string `json:"category"` + Files model.FileCollection `json:"files"` + FilesHash string `json:"filesHash"` + Preview string `json:"preview"` + Visibility string `json:"visibility"` +} + +// Validate validates the parameters. +func (p *CreateAssetParams) Validate() (ok bool, msg string) { + if p.DisplayName == "" { + return false, "missing displayName" + } else if !assetDisplayNameRE.Match([]byte(p.DisplayName)) { + return false, "invalid displayName" + } + if model.ParseAssetType(p.Type).String() != p.Type { + return false, "invalid type" + } + if p.Category == "" { + return false, "missing category" + } + if p.FilesHash == "" { + return false, "missing filesHash" + } + if model.ParseVisibility(p.Visibility).String() != p.Visibility { + return false, "invalid visibility" + } + return true, "" +} + +// CreateAsset creates an asset. +func (ctrl *Controller) CreateAsset(ctx context.Context, params *CreateAssetParams) (*AssetDTO, error) { + mUser, ok := UserFromContext(ctx) + if !ok { + return nil, ErrUnauthorized + } + + mAsset := model.Asset{ + OwnerID: mUser.ID, + DisplayName: params.DisplayName, + Type: model.ParseAssetType(params.Type), + Category: params.Category, + Files: params.Files, + FilesHash: params.FilesHash, + Visibility: model.ParseVisibility(params.Visibility), + } + if err := ctrl.db.WithContext(ctx).Create(&mAsset).Error; err != nil { + return nil, fmt.Errorf("failed to create asset: %w", err) + } + if err := ctrl.db.WithContext(ctx). + Preload("Owner"). + First(&mAsset). + Error; err != nil { + return nil, fmt.Errorf("failed to get asset: %w", err) + } + assetDTO := toAssetDTO(mAsset) + return &assetDTO, nil } // ListAssetsOrderBy is the order by condition for listing assets. type ListAssetsOrderBy string -var ( - DefaultOrder ListAssetsOrderBy = "default" - TimeDesc ListAssetsOrderBy = "time" - ClickCountDesc ListAssetsOrderBy = "clickCount" +const ( + ListAssetsOrderByCreatedAt ListAssetsOrderBy = "createdAt" + ListAssetsOrderByUpdatedAt ListAssetsOrderBy = "updatedAt" ) +// IsValid reports whether the order by condition is valid. +func (ob ListAssetsOrderBy) IsValid() bool { + switch ob { + case ListAssetsOrderByCreatedAt, ListAssetsOrderByUpdatedAt: + return true + } + return false +} + // ListAssetsParams holds parameters for listing assets. type ListAssetsParams struct { - // Keyword is the keyword filter for the display name, applied only if non-empty. - Keyword string - - // Owner is the owner filter, applied only if non-nil. + // Keyword filters assets by display name pattern. + // + // Applied only if non-nil. + Keyword *string + + // Owner filters assets by owner's username. + // + // Applied only if non-nil. Owner *string - // Category is the category filter, applied only if non-nil. - Category *string + // Type filters assets by type. + // + // Applied only if non-nil. + Type *string - // AccessType is the access type filter, applied only if non-nil. - AssetType *model.AssetType + // Category filters assets by category. + // + // Applied only if non-nil. + Category *string - // FilesHash is the files hash filter, applied only if non-nil. + // FilesHash filters assets by files hash. + // + // Applied only if non-nil. FilesHash *string - // IsPublic is the visibility filter, applied only if non-nil. - IsPublic *model.IsPublic + // Visibility filters assets by visibility. + // + // Applied only if non-nil. + Visibility *string - // OrderBy is the order by condition. + // OrderBy indicates the field by which to order the results. OrderBy ListAssetsOrderBy + // SortOrder indicates the order in which to sort the results. + SortOrder SortOrder + // Pagination is the pagination information. - Pagination model.Pagination + Pagination Pagination +} + +// NewListAssetsParams creates a new ListAssetsParams. +func NewListAssetsParams() *ListAssetsParams { + return &ListAssetsParams{ + OrderBy: ListAssetsOrderByCreatedAt, + SortOrder: SortOrderDesc, + Pagination: Pagination{Index: 1, Size: 20}, + } } // Validate validates the parameters. func (p *ListAssetsParams) Validate() (ok bool, msg string) { + if p.Type != nil && model.ParseAssetType(*p.Type).String() != *p.Type { + return false, "invalid type" + } + if p.Visibility != nil && model.ParseVisibility(*p.Visibility).String() != *p.Visibility { + return false, "invalid visibility" + } + if !p.OrderBy.IsValid() { + return false, "invalid orderBy" + } + if !p.SortOrder.IsValid() { + return false, "invalid sortOrder" + } + if !p.Pagination.IsValid() { + return false, "invalid pagination" + } return true, "" } // ListAssets lists assets. -func (ctrl *Controller) ListAssets(ctx context.Context, params *ListAssetsParams) (*model.ByPage[model.Asset], error) { - logger := log.GetReqLogger(ctx) - +func (ctrl *Controller) ListAssets(ctx context.Context, params *ListAssetsParams) (*ByPage[AssetDTO], error) { // Ensure non-owners can only see public assets. - if user, ok := UserFromContext(ctx); !ok || params.Owner == nil || user.Name != *params.Owner { - public := model.Public - params.IsPublic = &public + if mUser, ok := UserFromContext(ctx); !ok || params.Owner == nil || *params.Owner != mUser.Username { + public := model.VisibilityPublic.String() + params.Visibility = &public } - var wheres []model.FilterCondition - if params.Keyword != "" { - wheres = append(wheres, model.FilterCondition{Column: "display_name", Operation: "LIKE", Value: "%" + params.Keyword + "%"}) + query := ctrl.db.WithContext(ctx).Model(&model.Asset{}) + if params.Keyword != nil { + query = query.Where("asset.display_name LIKE ?", "%"+*params.Keyword+"%") } if params.Owner != nil { - wheres = append(wheres, model.FilterCondition{Column: "owner", Operation: "=", Value: *params.Owner}) + query = query.Joins("JOIN user ON user.id = asset.owner_id").Where("user.username = ?", *params.Owner) } - if params.Category != nil { - wheres = append(wheres, model.FilterCondition{Column: "category", Operation: "=", Value: *params.Category}) + if params.Type != nil { + query = query.Where("asset.type = ?", model.ParseAssetType(*params.Type)) } - if params.AssetType != nil { - wheres = append(wheres, model.FilterCondition{Column: "asset_type", Operation: "=", Value: *params.AssetType}) + if params.Category != nil { + query = query.Where("asset.category = ?", *params.Category) } if params.FilesHash != nil { - wheres = append(wheres, model.FilterCondition{Column: "files_hash", Operation: "=", Value: *params.FilesHash}) + query = query.Where("asset.files_hash = ?", *params.FilesHash) } - if params.IsPublic != nil { - wheres = append(wheres, model.FilterCondition{Column: "is_public", Operation: "=", Value: *params.IsPublic}) + if params.Visibility != nil { + query = query.Where("asset.visibility = ?", model.ParseVisibility(*params.Visibility)) } - - var orders []model.OrderByCondition switch params.OrderBy { - case TimeDesc: - orders = append(orders, model.OrderByCondition{Column: "c_time", Direction: "DESC"}) - case ClickCountDesc: - orders = append(orders, model.OrderByCondition{Column: "click_count", Direction: "DESC"}) + case ListAssetsOrderByCreatedAt: + query = query.Order(fmt.Sprintf("asset.created_at %s", params.SortOrder)) + case ListAssetsOrderByUpdatedAt: + query = query.Order(fmt.Sprintf("asset.updated_at %s", params.SortOrder)) } - assets, err := model.ListAssets(ctx, ctrl.db, params.Pagination, wheres, orders) - if err != nil { - logger.Printf("failed to list assets : %v", err) - return nil, err + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, fmt.Errorf("failed to count assets: %w", err) } - return assets, nil -} - -// AddAssetParams holds parameters for adding an asset. -type AddAssetParams struct { - DisplayName string `json:"displayName"` - Owner string `json:"owner"` - Category string `json:"category"` - AssetType model.AssetType `json:"assetType"` - Files model.FileCollection `json:"files"` - FilesHash string `json:"filesHash"` - Preview string `json:"preview"` - IsPublic model.IsPublic `json:"isPublic"` -} -// Validate validates the parameters. -func (p *AddAssetParams) Validate() (ok bool, msg string) { - if p.DisplayName == "" { - return false, "missing displayName" - } else if !assetDisplayNameRE.Match([]byte(p.DisplayName)) { - return false, "invalid displayName" - } - if p.Owner == "" { - return false, "missing owner" - } - if p.Category == "" { - return false, "missing category" + var mAssets []model.Asset + if err := query. + Preload("Owner"). + Offset(params.Pagination.Offset()). + Limit(params.Pagination.Size). + Find(&mAssets). + Error; err != nil { + return nil, fmt.Errorf("failed to list assets: %w", err) } - switch p.AssetType { - case model.AssetTypeSprite, model.AssetTypeBackdrop, model.AssetTypeSound: - default: - return false, "invalid assetType" + assetDTOs := make([]AssetDTO, len(mAssets)) + for i, mAsset := range mAssets { + assetDTOs[i] = toAssetDTO(mAsset) } - if p.FilesHash == "" { - return false, "missing filesHash" - } - switch p.IsPublic { - case model.Personal, model.Public: - default: - return false, "invalid isPublic" - } - return true, "" + return &ByPage[AssetDTO]{ + Total: total, + Data: assetDTOs, + }, nil } -// AddAsset adds an asset. -func (ctrl *Controller) AddAsset(ctx context.Context, params *AddAssetParams) (*model.Asset, error) { - logger := log.GetReqLogger(ctx) - - user, err := EnsureUser(ctx, params.Owner) +// GetAsset gets asset by id. +func (ctrl *Controller) GetAsset(ctx context.Context, id string) (*AssetDTO, error) { + mAssetID, err := strconv.ParseInt(id, 10, 64) if err != nil { - return nil, err + return nil, fmt.Errorf("invalid asset id: %w", err) } - - asset, err := model.AddAsset(ctx, ctrl.db, &model.Asset{ - DisplayName: params.DisplayName, - Owner: user.Name, - Category: params.Category, - AssetType: params.AssetType, - Files: params.Files, - FilesHash: params.FilesHash, - Preview: params.Preview, - IsPublic: params.IsPublic, - }) + mAsset, err := ctrl.ensureAsset(ctx, mAssetID, false) if err != nil { - logger.Printf("failed to add asset: %v", err) return nil, err } - return asset, nil + assetDTO := toAssetDTO(*mAsset) + return &assetDTO, nil } // UpdateAssetParams holds parameters for updating an asset. type UpdateAssetParams struct { DisplayName string `json:"displayName"` + Type string `json:"type"` Category string `json:"category"` - AssetType model.AssetType `json:"assetType"` Files model.FileCollection `json:"files"` FilesHash string `json:"filesHash"` - Preview string `json:"preview"` - IsPublic model.IsPublic `json:"isPublic"` + Visibility string `json:"visibility"` } // Validate validates the parameters. @@ -207,78 +299,71 @@ func (p *UpdateAssetParams) Validate() (ok bool, msg string) { } else if !assetDisplayNameRE.Match([]byte(p.DisplayName)) { return false, "invalid displayName" } + if model.ParseAssetType(p.Type).String() != p.Type { + return false, "invalid type" + } if p.Category == "" { return false, "missing category" } - switch p.AssetType { - case model.AssetTypeSprite, model.AssetTypeBackdrop, model.AssetTypeSound: - default: - return false, "invalid assetType" - } if p.FilesHash == "" { return false, "missing filesHash" } - switch p.IsPublic { - case model.Personal, model.Public: - default: - return false, "invalid isPublic" + if model.ParseVisibility(p.Visibility).String() != p.Visibility { + return false, "invalid visibility" } return true, "" } // UpdateAsset updates an asset. -func (ctrl *Controller) UpdateAsset(ctx context.Context, id string, updates *UpdateAssetParams) (*model.Asset, error) { - logger := log.GetReqLogger(ctx) - - asset, err := ctrl.ensureAsset(ctx, id, true) +func (ctrl *Controller) UpdateAsset(ctx context.Context, id string, params *UpdateAssetParams) (*AssetDTO, error) { + mAssetID, err := strconv.ParseInt(id, 10, 64) if err != nil { - return nil, err + return nil, fmt.Errorf("invalid asset id: %w", err) } - - updatedAsset, err := model.UpdateAssetByID(ctx, ctrl.db, asset.ID, &model.Asset{ - DisplayName: updates.DisplayName, - Category: updates.Category, - AssetType: updates.AssetType, - Files: updates.Files, - FilesHash: updates.FilesHash, - Preview: updates.Preview, - IsPublic: updates.IsPublic, - }) + mAsset, err := ctrl.ensureAsset(ctx, mAssetID, true) if err != nil { - logger.Printf("failed to update asset: %v", err) return nil, err } - return updatedAsset, nil -} - -// IncreaseAssetClickCount increases the click count of an asset. -func (ctrl *Controller) IncreaseAssetClickCount(ctx context.Context, id string) error { - logger := log.GetReqLogger(ctx) - - asset, err := ctrl.ensureAsset(ctx, id, false) - if err != nil { - return err + updates := map[string]any{} + if params.DisplayName != mAsset.DisplayName { + updates["display_name"] = params.DisplayName } - - if err := model.IncreaseAssetClickCount(ctx, ctrl.db, asset.ID); err != nil { - logger.Printf("failed to increase asset click count: %v", err) - return err + if params.Type != mAsset.Type.String() { + updates["type"] = model.ParseAssetType(params.Type) } - return nil + if params.Category != mAsset.Category { + updates["category"] = params.Category + } + if !maps.Equal(params.Files, mAsset.Files) { + updates["files"] = params.Files + } + if params.FilesHash != mAsset.FilesHash { + updates["files_hash"] = params.FilesHash + } + if params.Visibility != mAsset.Visibility.String() { + updates["visibility"] = model.ParseVisibility(params.Visibility) + } + if len(updates) > 0 { + if err := ctrl.db.WithContext(ctx).Model(mAsset).Omit("Owner").Updates(updates).Error; err != nil { + return nil, fmt.Errorf("failed to update asset: %w", err) + } + } + assetDTO := toAssetDTO(*mAsset) + return &assetDTO, nil } // DeleteAsset deletes an asset. func (ctrl *Controller) DeleteAsset(ctx context.Context, id string) error { - logger := log.GetReqLogger(ctx) - - asset, err := ctrl.ensureAsset(ctx, id, true) + mAssetID, err := strconv.ParseInt(id, 10, 64) if err != nil { - return err + return fmt.Errorf("invalid asset id: %w", err) } - - if err := model.DeleteAssetByID(ctx, ctrl.db, asset.ID); err != nil { - logger.Printf("failed to delete asset: %v", err) + mAsset, err := ctrl.ensureAsset(ctx, mAssetID, true) + if err != nil { return err } + if err := ctrl.db.WithContext(ctx).Delete(mAsset).Error; err != nil { + return fmt.Errorf("failed to delete asset: %w", err) + } return nil } diff --git a/spx-backend/internal/controller/asset_test.go b/spx-backend/internal/controller/asset_test.go index c78b319bf..c53179260 100644 --- a/spx-backend/internal/controller/asset_test.go +++ b/spx-backend/internal/controller/asset_test.go @@ -2,556 +2,548 @@ package controller import ( "context" - "database/sql" + "fmt" + "regexp" + "strconv" "strings" "testing" "github.com/DATA-DOG/go-sqlmock" "github.com/goplus/builder/spx-backend/internal/model" + "github.com/goplus/builder/spx-backend/internal/model/modeltest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) func TestControllerEnsureAsset(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + assetDBColumns, err := modeltest.ExtractDBColumns(db, model.Asset{}) + require.NoError(t, err) + generateAssetDBRows, err := modeltest.NewDBRowsGenerator(db, model.Asset{}) + require.NoError(t, err) + t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner"}). - AddRow(1, "fake-asset", "fake-name")) - asset, err := ctrl.ensureAsset(ctx, "1", false) - require.NoError(t, err) - require.NotNil(t, asset) - assert.Equal(t, "1", asset.ID) - }) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) - t.Run("NoAsset", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - _, err = ctrl.ensureAsset(ctx, "1", false) - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) - }) + mAsset := model.Asset{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID, + Visibility: model.VisibilityPublic, + } - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("id = ?", mAsset.ID). + First(&model.Asset{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(assetDBColumns).AddRows(generateAssetDBRows(mAsset)...)) - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner"}). - AddRow(1, "fake-asset", "fake-name")) - _, err = ctrl.ensureAsset(ctx, "1", false) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) - t.Run("NoUserWithPublicAsset", func(t *testing.T) { - ctrl, mock, err := newTestController(t) + asset, err := ctrl.ensureAsset(ctx, mAsset.ID, false) require.NoError(t, err) + assert.Equal(t, mAsset.ID, asset.ID) - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "is_public"}). - AddRow(1, "fake-asset", "fake-name", model.Public)) - asset, err := ctrl.ensureAsset(ctx, "1", false) - require.NoError(t, err) - require.NotNil(t, asset) - assert.Equal(t, "1", asset.ID) + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("NoUserWithPublicAssetButCheckOwner", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + t.Run("AssetNotFound", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "is_public"}). - AddRow(1, "fake-asset", "fake-name", model.Public)) - _, err = ctrl.ensureAsset(ctx, "1", true) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) + ctx := newContextWithTestUser(context.Background()) - t.Run("NoUserWithPersonalAsset", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + var mAssetID int64 = 1 - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "is_public"}). - AddRow(1, "fake-asset", "fake-name", model.Personal)) - _, err = ctrl.ensureAsset(ctx, "1", false) + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("id = ?", mAssetID). + First(&model.Asset{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnError(gorm.ErrRecordNotFound) + + _, err := ctrl.ensureAsset(ctx, mAssetID, false) require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) -} -func TestControllerGetAsset(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + t.Run("Unauthorized", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner"}). - AddRow(1, "fake-asset", "fake-name")) - asset, err := ctrl.GetAsset(ctx, "1") - require.NoError(t, err) - require.NotNil(t, asset) - assert.Equal(t, "1", asset.ID) - }) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) - t.Run("NoAsset", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + mAsset := model.Asset{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID + 1, + Visibility: model.VisibilityPrivate, + } - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - _, err = ctrl.GetAsset(ctx, "1") + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("id = ?", mAsset.ID). + First(&model.Asset{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(assetDBColumns).AddRows(generateAssetDBRows(mAsset)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mAsset.OwnerID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(model.User{ + Model: model.Model{ID: mAsset.OwnerID}, + Username: "otheruser", + })...)) + + _, err := ctrl.ensureAsset(ctx, mAsset.ID, false) require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) + assert.ErrorIs(t, err, ErrForbidden) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) } -func TestListAssetsParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - paramsOwner := "fake-name" - paramsCategory := "fake-category" - paramsAssetType := model.AssetTypeSprite - paramsIsPublic := model.Personal - params := &ListAssetsParams{ - Keyword: "fake", - Owner: ¶msOwner, - Category: ¶msCategory, - AssetType: ¶msAssetType, - IsPublic: ¶msIsPublic, - OrderBy: DefaultOrder, - Pagination: model.Pagination{Index: 1, Size: 10}, +func TestCreateAssetParams(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + params := &CreateAssetParams{ + DisplayName: "Test Asset", + Type: "sprite", + Category: "characters", + FilesHash: "abc123", + Visibility: "public", } ok, msg := params.Validate() assert.True(t, ok) assert.Empty(t, msg) }) -} - -func TestControllerListAssets(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - ctx := newContextWithTestUser(context.Background()) - paramsOwner := "fake-name" - paramsCategory := "fake-category" - paramsAssetType := model.AssetTypeSprite - paramsFilesHash := "fake-files-hash" - paramsIsPublic := model.Personal - params := &ListAssetsParams{ - Keyword: "fake", - Owner: ¶msOwner, - Category: ¶msCategory, - AssetType: ¶msAssetType, - FilesHash: ¶msFilesHash, - IsPublic: ¶msIsPublic, - OrderBy: DefaultOrder, - Pagination: model.Pagination{Index: 1, Size: 10}, + t.Run("MissingDisplayName", func(t *testing.T) { + params := &CreateAssetParams{ + DisplayName: "", + Type: "sprite", + Category: "characters", + FilesHash: "abc123", + Visibility: "public", } - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM asset WHERE display_name LIKE \? AND owner = \? AND category = \? AND asset_type = \? AND files_hash = \? AND is_public = \? AND status != \?`). - WithArgs("%"+params.Keyword+"%", params.Owner, params.Category, model.AssetTypeSprite, params.FilesHash, model.Personal, model.StatusDeleted). - WillReturnRows(mock.NewRows([]string{"COUNT(1)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM asset WHERE display_name LIKE \? AND owner = \? AND category = \? AND asset_type = \? AND files_hash = \? AND is_public = \? AND status != \? ORDER BY id ASC LIMIT \?, \? `). - WithArgs("%"+params.Keyword+"%", params.Owner, params.Category, model.AssetTypeSprite, params.FilesHash, model.Personal, model.StatusDeleted, 0, 10). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner"}). - AddRow(1, "fake-asset", "fake-name")) - assets, err := ctrl.ListAssets(ctx, params) - require.NoError(t, err) - require.NotNil(t, assets) - assert.Len(t, assets.Data, 1) - assert.Equal(t, "1", assets.Data[0].ID) + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "missing displayName", msg) }) - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - paramsOwner := "fake-name" - paramsCategory := "fake-category" - paramsAssetType := model.AssetTypeSprite - paramsFilesHash := "fake-files-hash" - paramsIsPublic := model.Personal - params := &ListAssetsParams{ - Keyword: "fake", - Owner: ¶msOwner, - Category: ¶msCategory, - AssetType: ¶msAssetType, - FilesHash: ¶msFilesHash, - IsPublic: ¶msIsPublic, - OrderBy: TimeDesc, - Pagination: model.Pagination{Index: 1, Size: 10}, + t.Run("InvalidDisplayName", func(t *testing.T) { + params := &CreateAssetParams{ + DisplayName: strings.Repeat("a", 256), + Type: "sprite", + Category: "characters", + FilesHash: "abc123", + Visibility: "public", } - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM asset WHERE display_name LIKE \? AND owner = \? AND category = \? AND asset_type = \? AND files_hash = \? AND is_public = \? AND status != \?`). - WithArgs("%"+params.Keyword+"%", params.Owner, params.Category, model.AssetTypeSprite, params.FilesHash, model.Public, model.StatusDeleted). - WillReturnRows(mock.NewRows([]string{"COUNT(1)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM asset WHERE display_name LIKE \? AND owner = \? AND category = \? AND asset_type = \? AND files_hash = \? AND is_public = \? AND status != \? ORDER BY c_time DESC LIMIT \?, \? `). - WithArgs("%"+params.Keyword+"%", params.Owner, params.Category, model.AssetTypeSprite, params.FilesHash, model.Public, model.StatusDeleted, 0, 10). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner"}). - AddRow(1, "fake-asset", "fake-name")) - assets, err := ctrl.ListAssets(ctx, params) - require.NoError(t, err) - require.NotNil(t, assets) - assert.Len(t, assets.Data, 1) - assert.Equal(t, "1", assets.Data[0].ID) + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "invalid displayName", msg) }) - t.Run("NoOwner", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + t.Run("InvalidType", func(t *testing.T) { + params := &CreateAssetParams{ + DisplayName: "Test Asset", + Type: "invalid", + Category: "characters", + FilesHash: "abc123", + Visibility: "public", + } + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "invalid type", msg) + }) - ctx := newContextWithTestUser(context.Background()) - paramsCategory := "fake-category" - paramsAssetType := model.AssetTypeSprite - paramsFilesHash := "fake-files-hash" - paramsIsPublic := model.Personal - params := &ListAssetsParams{ - Keyword: "fake", - Category: ¶msCategory, - AssetType: ¶msAssetType, - FilesHash: ¶msFilesHash, - IsPublic: ¶msIsPublic, - OrderBy: ClickCountDesc, - Pagination: model.Pagination{Index: 1, Size: 10}, + t.Run("MissingCategory", func(t *testing.T) { + params := &CreateAssetParams{ + DisplayName: "Test Asset", + Type: "sprite", + FilesHash: "abc123", + Visibility: "public", } - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM asset WHERE display_name LIKE \? AND category = \? AND asset_type = \? AND files_hash = \? AND is_public = \? AND status != \?`). - WithArgs("%"+params.Keyword+"%", params.Category, model.AssetTypeSprite, params.FilesHash, model.Public, model.StatusDeleted). - WillReturnRows(mock.NewRows([]string{"COUNT(1)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM asset WHERE display_name LIKE \? AND category = \? AND asset_type = \? AND files_hash = \? AND is_public = \? AND status != \? ORDER BY click_count DESC LIMIT \?, \? `). - WithArgs("%"+params.Keyword+"%", params.Category, model.AssetTypeSprite, params.FilesHash, model.Public, model.StatusDeleted, 0, 10). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner"}). - AddRow(1, "fake-asset", "fake-name")) - assets, err := ctrl.ListAssets(ctx, params) - require.NoError(t, err) - require.NotNil(t, assets) - assert.Len(t, assets.Data, 1) - assert.Equal(t, "1", assets.Data[0].ID) + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "missing category", msg) }) - t.Run("DifferentOwner", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + t.Run("MissingFilesHash", func(t *testing.T) { + params := &CreateAssetParams{ + DisplayName: "Test Asset", + Type: "sprite", + Category: "characters", + Visibility: "public", + } + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "missing filesHash", msg) + }) - ctx := newContextWithTestUser(context.Background()) - paramsOwner := "another-fake-name" - paramsCategory := "fake-category" - paramsAssetType := model.AssetTypeSprite - paramsFilesHash := "fake-files-hash" - paramsIsPublic := model.Personal - params := &ListAssetsParams{ - Keyword: "fake", - Owner: ¶msOwner, - Category: ¶msCategory, - AssetType: ¶msAssetType, - FilesHash: ¶msFilesHash, - IsPublic: ¶msIsPublic, - OrderBy: DefaultOrder, - Pagination: model.Pagination{Index: 1, Size: 10}, + t.Run("InvalidVisibility", func(t *testing.T) { + params := &CreateAssetParams{ + DisplayName: "Test Asset", + Type: "sprite", + Category: "characters", + FilesHash: "abc123", + Visibility: "invalid", } - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM asset WHERE display_name LIKE \? AND owner = \? AND category = \? AND asset_type = \? AND files_hash = \? AND is_public = \? AND status != \?`). - WithArgs("%"+params.Keyword+"%", params.Owner, params.Category, model.AssetTypeSprite, params.FilesHash, model.Public, model.StatusDeleted). - WillReturnRows(mock.NewRows([]string{"COUNT(1)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM asset WHERE display_name LIKE \? AND owner = \? AND category = \? AND asset_type = \? AND files_hash = \? AND is_public = \? AND status != \? ORDER BY id ASC LIMIT \?, \? `). - WithArgs("%"+params.Keyword+"%", params.Owner, params.Category, model.AssetTypeSprite, params.FilesHash, model.Public, model.StatusDeleted, 0, 10). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner"}). - AddRow(1, "fake-asset", "another-fake-name")) - assets, err := ctrl.ListAssets(ctx, params) - require.NoError(t, err) - require.NotNil(t, assets) - assert.Len(t, assets.Data, 1) - assert.Equal(t, "1", assets.Data[0].ID) + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "invalid visibility", msg) }) +} - t.Run("ClosedDB", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - ctrl.db.Close() +func TestControllerCreateAsset(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + assetDBColumns, err := modeltest.ExtractDBColumns(db, model.Asset{}) + require.NoError(t, err) + generateAssetDBRows, err := modeltest.NewDBRowsGenerator(db, model.Asset{}) + require.NoError(t, err) + + t.Run("Normal", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - paramsOwner := "fake-name" - paramsCategory := "fake-category" - paramsAssetType := model.AssetTypeSprite - paramsFilesHash := "fake-files-hash" - paramsIsPublic := model.Personal - params := &ListAssetsParams{ - Keyword: "fake", - Owner: ¶msOwner, - Category: ¶msCategory, - AssetType: ¶msAssetType, - FilesHash: ¶msFilesHash, - IsPublic: ¶msIsPublic, - OrderBy: DefaultOrder, - Pagination: model.Pagination{Index: 1, Size: 10}, + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + params := &CreateAssetParams{ + DisplayName: "Test Asset", + Type: "sprite", + Category: "characters", + FilesHash: "abc123", + Visibility: "public", } - _, err = ctrl.ListAssets(ctx, params) + + dbMock.ExpectBegin() + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Create(&model.Asset{}). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnResult(sqlmock.NewResult(1, 1)) + dbMock.ExpectCommit() + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + First(&model.Asset{Model: model.Model{ID: 1}}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(assetDBColumns).AddRows(generateAssetDBRows(model.Asset{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID, + DisplayName: params.DisplayName, + Type: model.ParseAssetType(params.Type), + Category: params.Category, + FilesHash: params.FilesHash, + Visibility: model.ParseVisibility(params.Visibility), + })...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + assetDTO, err := ctrl.CreateAsset(ctx, params) + require.NoError(t, err) + assert.Equal(t, params.DisplayName, assetDTO.DisplayName) + assert.Equal(t, params.Type, assetDTO.Type) + assert.Equal(t, params.Category, assetDTO.Category) + assert.Equal(t, params.FilesHash, assetDTO.FilesHash) + assert.Equal(t, params.Visibility, assetDTO.Visibility) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("Unauthorized", func(t *testing.T) { + ctrl, _, closeDB := newTestController(t) + defer closeDB() + + params := &CreateAssetParams{ + DisplayName: "Test Asset", + Type: "sprite", + Category: "characters", + FilesHash: "abc123", + Visibility: "public", + } + + _, err := ctrl.CreateAsset(context.Background(), params) require.Error(t, err) - assert.EqualError(t, err, "sql: database is closed") + assert.ErrorIs(t, err, ErrUnauthorized) }) } -func TestAddAssetParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - params := &AddAssetParams{ - DisplayName: "fake-display-name", - Owner: "fake-owner", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } +func TestListAssetsOrderBy(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + assert.True(t, ListAssetsOrderByCreatedAt.IsValid()) + assert.True(t, ListAssetsOrderByUpdatedAt.IsValid()) + }) + + t.Run("Invalid", func(t *testing.T) { + assert.False(t, ListAssetsOrderBy("invalid").IsValid()) + }) +} + +func TestListAssetsParams(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + params := NewListAssetsParams() ok, msg := params.Validate() assert.True(t, ok) assert.Empty(t, msg) }) - t.Run("EmptyDisplayName", func(t *testing.T) { - params := &AddAssetParams{ - DisplayName: "", - Owner: "fake-owner", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } + t.Run("InvalidType", func(t *testing.T) { + params := NewListAssetsParams() + invalidType := "invalid" + params.Type = &invalidType ok, msg := params.Validate() assert.False(t, ok) - assert.Equal(t, "missing displayName", msg) + assert.Equal(t, "invalid type", msg) }) - t.Run("InvalidDisplayName", func(t *testing.T) { - params := &AddAssetParams{ - DisplayName: strings.Repeat("fake-asset", 11), - Owner: "fake-owner", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } + t.Run("InvalidVisibility", func(t *testing.T) { + params := NewListAssetsParams() + invalidVisibility := "invalid" + params.Visibility = &invalidVisibility ok, msg := params.Validate() assert.False(t, ok) - assert.Equal(t, "invalid displayName", msg) + assert.Equal(t, "invalid visibility", msg) }) - t.Run("EmptyOwner", func(t *testing.T) { - params := &AddAssetParams{ - DisplayName: "fake-display-name", - Owner: "", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } + t.Run("InvalidOrderBy", func(t *testing.T) { + params := NewListAssetsParams() + params.OrderBy = "invalid" ok, msg := params.Validate() assert.False(t, ok) - assert.Equal(t, "missing owner", msg) - }) - - t.Run("EmptyCategory", func(t *testing.T) { - params := &AddAssetParams{ - DisplayName: "fake-display-name", - Owner: "fake-owner", - Category: "", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing category", msg) + assert.Equal(t, "invalid orderBy", msg) }) - t.Run("InvalidAssetType", func(t *testing.T) { - params := &AddAssetParams{ - DisplayName: "fake-display-name", - Owner: "fake-owner", - Category: "fake-category", - AssetType: -1, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid assetType", msg) - }) - - t.Run("EmptyFilesHash", func(t *testing.T) { - params := &AddAssetParams{ - DisplayName: "fake-display-name", - Owner: "fake-owner", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "", - Preview: "fake-preview", - IsPublic: model.Personal, - } + t.Run("InvalidSortOrder", func(t *testing.T) { + params := NewListAssetsParams() + params.SortOrder = "invalid" ok, msg := params.Validate() assert.False(t, ok) - assert.Equal(t, "missing filesHash", msg) + assert.Equal(t, "invalid sortOrder", msg) }) - t.Run("InvalidIsPublic", func(t *testing.T) { - params := &AddAssetParams{ - DisplayName: "fake-display-name", - Owner: "fake-owner", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: -1, - } + t.Run("InvalidPagination", func(t *testing.T) { + params := NewListAssetsParams() + params.Pagination.Index = 0 ok, msg := params.Validate() assert.False(t, ok) - assert.Equal(t, "invalid isPublic", msg) + assert.Equal(t, "invalid pagination", msg) }) } -func TestControllerAddAsset(t *testing.T) { +func TestControllerListAssets(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + assetDBColumns, err := modeltest.ExtractDBColumns(db, model.Asset{}) + require.NoError(t, err) + generateAssetDBRows, err := modeltest.NewDBRowsGenerator(db, model.Asset{}) + require.NoError(t, err) + t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - params := &AddAssetParams{ - DisplayName: "fake-asset", - Owner: "fake-name", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - mock.ExpectExec(`INSERT INTO asset \(.+\) VALUES \(\?,\?,\?,\?,\?,\?,\?,\?,\?,\?,\?,\?\)`). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner"}). - AddRow(1, "fake-asset", "fake-name")) - asset, err := ctrl.AddAsset(ctx, params) - require.NoError(t, err) - require.NotNil(t, asset) - assert.Equal(t, "1", asset.ID) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + params := NewListAssetsParams() + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Model(&model.Asset{}). + Where("asset.visibility = ?", model.VisibilityPublic). + Count(new(int64)). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("asset.visibility = ?", model.VisibilityPublic). + Order("asset.created_at desc"). + Limit(params.Pagination.Size). + Find(&[]model.Asset{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(assetDBColumns).AddRows( + generateAssetDBRows( + model.Asset{Model: model.Model{ID: 1}, OwnerID: mUser.ID, Visibility: model.VisibilityPublic}, + model.Asset{Model: model.Model{ID: 2}, OwnerID: mUser.ID, Visibility: model.VisibilityPublic}, + )..., + )) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + result, err := ctrl.ListAssets(ctx, params) + require.NoError(t, err) + assert.Equal(t, int64(2), result.Total) + assert.Len(t, result.Data, 2) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) +} - t.Run("NoUser", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) +func TestControllerGetAsset(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + assetDBColumns, err := modeltest.ExtractDBColumns(db, model.Asset{}) + require.NoError(t, err) + generateAssetDBRows, err := modeltest.NewDBRowsGenerator(db, model.Asset{}) + require.NoError(t, err) - ctx := context.Background() - params := &AddAssetParams{ - DisplayName: "fake-asset", - Owner: "fake-name", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, + t.Run("Normal", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mAsset := model.Asset{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID, + Visibility: model.VisibilityPublic, } - _, err = ctrl.AddAsset(ctx, params) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) - t.Run("UnexpectedUser", func(t *testing.T) { - ctrl, _, err := newTestController(t) + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("id = ?", mAsset.ID). + First(&model.Asset{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(assetDBColumns).AddRows(generateAssetDBRows(mAsset)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + asset, err := ctrl.GetAsset(ctx, "1") require.NoError(t, err) + assert.Equal(t, strconv.FormatInt(mAsset.ID, 10), asset.ID) - ctx := newContextWithTestUser(context.Background()) - params := &AddAssetParams{ - DisplayName: "fake-asset", - Owner: "another-fake-name", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - _, err = ctrl.AddAsset(ctx, params) - require.Error(t, err) - assert.ErrorIs(t, err, ErrForbidden) + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("ClosedDB", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - ctrl.db.Close() + t.Run("AssetNotFound", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - params := &AddAssetParams{ - DisplayName: "fake-asset", - Owner: "fake-name", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - _, err = ctrl.AddAsset(ctx, params) + + var mAssetID int64 = 1 + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("id = ?", mAssetID). + First(&model.Asset{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnError(gorm.ErrRecordNotFound) + + _, err := ctrl.GetAsset(ctx, "1") require.Error(t, err) - assert.EqualError(t, err, "sql: database is closed") + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) } -func TestUpdateAssetParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { +func TestUpdateAssetParams(t *testing.T) { + t.Run("Valid", func(t *testing.T) { params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, + DisplayName: "Updated Asset", + Type: "sprite", + Category: "characters", + FilesHash: "def456", + Visibility: "public", } ok, msg := params.Validate() assert.True(t, ok) assert.Empty(t, msg) }) - t.Run("EmptyDisplayName", func(t *testing.T) { + t.Run("MissingDisplayName", func(t *testing.T) { params := &UpdateAssetParams{ DisplayName: "", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, + Type: "sprite", + Category: "characters", + FilesHash: "def456", + Visibility: "public", } ok, msg := params.Validate() assert.False(t, ok) @@ -560,340 +552,374 @@ func TestUpdateAssetParamsValidate(t *testing.T) { t.Run("InvalidDisplayName", func(t *testing.T) { params := &UpdateAssetParams{ - DisplayName: strings.Repeat("fake-asset", 11), - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, + DisplayName: strings.Repeat("a", 256), + Type: "sprite", + Category: "characters", + FilesHash: "def456", + Visibility: "public", } ok, msg := params.Validate() assert.False(t, ok) assert.Equal(t, "invalid displayName", msg) }) - t.Run("EmptyCategory", func(t *testing.T) { + t.Run("InvalidType", func(t *testing.T) { params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, + DisplayName: "Updated Asset", + Type: "invalid", + Category: "characters", + FilesHash: "def456", + Visibility: "public", } ok, msg := params.Validate() assert.False(t, ok) - assert.Equal(t, "missing category", msg) + assert.Equal(t, "invalid type", msg) }) - t.Run("InvalidAssetType", func(t *testing.T) { + t.Run("MissingCategory", func(t *testing.T) { params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "fake-category", - AssetType: -1, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, + DisplayName: "Updated Asset", + Type: "sprite", + FilesHash: "def456", + Visibility: "public", } ok, msg := params.Validate() assert.False(t, ok) - assert.Equal(t, "invalid assetType", msg) + assert.Equal(t, "missing category", msg) }) - t.Run("EmptyFilesHash", func(t *testing.T) { + t.Run("MissingFilesHash", func(t *testing.T) { params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "", - Preview: "fake-preview", - IsPublic: model.Personal, + DisplayName: "Updated Asset", + Type: "sprite", + Category: "characters", + Visibility: "public", } ok, msg := params.Validate() assert.False(t, ok) assert.Equal(t, "missing filesHash", msg) }) - t.Run("InvalidIsPublic", func(t *testing.T) { + t.Run("InvalidVisibility", func(t *testing.T) { params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: -1, + DisplayName: "Updated Asset", + Type: "sprite", + Category: "characters", + FilesHash: "def456", + Visibility: "invalid", } ok, msg := params.Validate() assert.False(t, ok) - assert.Equal(t, "invalid isPublic", msg) + assert.Equal(t, "invalid visibility", msg) }) } func TestControllerUpdateAsset(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + assetDBColumns, err := modeltest.ExtractDBColumns(db, model.Asset{}) + require.NoError(t, err) + generateAssetDBRows, err := modeltest.NewDBRowsGenerator(db, model.Asset{}) + require.NoError(t, err) + t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mAsset := model.Asset{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID, + DisplayName: "Old Name", + Type: model.AssetTypeSprite, + Category: "old-category", + FilesHash: "old-hash", + Visibility: model.VisibilityPublic, } - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - mock.ExpectExec(`UPDATE asset SET u_time=\?,display_name=\?,category=\?,asset_type=\?,files=\?,files_hash=\?,preview=\?,is_public=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), params.DisplayName, params.Category, params.AssetType, []byte("{}"), params.FilesHash, params.Preview, params.IsPublic, "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Public)) - asset, err := ctrl.UpdateAsset(ctx, "1", params) - require.NoError(t, err) - require.NotNil(t, asset) - assert.Equal(t, "1", asset.ID) - assert.Equal(t, model.Public, asset.IsPublic) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - ctx := context.Background() params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, + DisplayName: "Updated Asset", + Type: "backdrop", + Category: "new-category", + FilesHash: "new-hash", + Visibility: "private", } - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - _, err = ctrl.UpdateAsset(ctx, "1", params) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) - t.Run("UnexpectedUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("id = ?", mAsset.ID). + First(&model.Asset{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(assetDBColumns).AddRows(generateAssetDBRows(mAsset)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + dbMock.ExpectBegin() + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.Asset{Model: mAsset.Model}). + Updates(map[string]any{ + "display_name": params.DisplayName, + "type": model.ParseAssetType(params.Type), + "category": params.Category, + "files_hash": params.FilesHash, + "visibility": model.ParseVisibility(params.Visibility), + }). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[5] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(sqlmock.NewResult(0, 1)) + dbMock.ExpectCommit() + + updatedAsset, err := ctrl.UpdateAsset(ctx, "1", params) + require.NoError(t, err) + assert.Equal(t, params.DisplayName, updatedAsset.DisplayName) + assert.Equal(t, params.Type, updatedAsset.Type) + assert.Equal(t, params.Category, updatedAsset.Category) + assert.Equal(t, params.FilesHash, updatedAsset.FilesHash) + assert.Equal(t, params.Visibility, updatedAsset.Visibility) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("AssetNotFound", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "another-fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - _, err = ctrl.UpdateAsset(ctx, "1", params) - require.Error(t, err) - assert.ErrorIs(t, err, ErrForbidden) - }) - t.Run("NoAsset", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + var mAssetID int64 = 1 - ctx := newContextWithTestUser(context.Background()) params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, + DisplayName: "Updated Asset", + Type: "backdrop", + Category: "new-category", + FilesHash: "new-hash", + Visibility: "private", } - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - _, err = ctrl.UpdateAsset(ctx, "1", params) + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("id = ?", mAssetID). + First(&model.Asset{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnError(gorm.ErrRecordNotFound) + + _, err := ctrl.UpdateAsset(ctx, "1", params) require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + t.Run("Unauthorized", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mAsset := model.Asset{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID + 1, + DisplayName: "Old Name", + Type: model.AssetTypeSprite, + Category: "old-category", + FilesHash: "old-hash", + Visibility: model.VisibilityPublic, + } + params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, + DisplayName: "Updated Asset", + Type: "backdrop", + Category: "new-category", + FilesHash: "new-hash", + Visibility: "private", } - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - mock.ExpectExec(`UPDATE asset SET u_time=\?,display_name=\?,category=\?,asset_type=\?,files=\?,files_hash=\?,preview=\?,is_public=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), params.DisplayName, params.Category, params.AssetType, []byte("{}"), params.FilesHash, params.Preview, params.IsPublic, "1"). - WillReturnError(sql.ErrConnDone) - _, err = ctrl.UpdateAsset(ctx, "1", params) + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("id = ?", mAsset.ID). + First(&model.Asset{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(assetDBColumns).AddRows(generateAssetDBRows(mAsset)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mAsset.OwnerID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(model.User{ + Model: model.Model{ID: mAsset.OwnerID}, + Username: "otheruser", + })...)) + + _, err := ctrl.UpdateAsset(ctx, "1", params) require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) + assert.ErrorIs(t, err, ErrForbidden) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) } -func TestControllerIncreaseAssetClickCount(t *testing.T) { +func TestControllerDeleteAsset(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + assetDBColumns, err := modeltest.ExtractDBColumns(db, model.Asset{}) + require.NoError(t, err) + generateAssetDBRows, err := modeltest.NewDBRowsGenerator(db, model.Asset{}) + require.NoError(t, err) + t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - mock.ExpectExec(`UPDATE asset SET u_time = \?, click_count = click_count \+ 1 WHERE id = \?`). - WithArgs(sqlmock.AnyArg(), "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - err = ctrl.IncreaseAssetClickCount(ctx, "1") - require.NoError(t, err) - }) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + mAsset := model.Asset{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID, + Visibility: model.VisibilityPublic, + } - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - err = ctrl.IncreaseAssetClickCount(ctx, "1") - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("id = ?", mAsset.ID). + First(&model.Asset{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(assetDBColumns).AddRows(generateAssetDBRows(mAsset)...)) - t.Run("UnexpectedUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "another-fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - err = ctrl.IncreaseAssetClickCount(ctx, "1") - require.Error(t, err) - assert.ErrorIs(t, err, ErrForbidden) - }) + dbMock.ExpectBegin() + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Delete(&model.Asset{Model: mAsset.Model}). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnResult(sqlmock.NewResult(0, 1)) + dbMock.ExpectCommit() - t.Run("NoAsset", func(t *testing.T) { - ctrl, mock, err := newTestController(t) + err := ctrl.DeleteAsset(ctx, "1") require.NoError(t, err) - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - err = ctrl.IncreaseAssetClickCount(ctx, "1") - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + t.Run("AssetNotFound", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - mock.ExpectExec(`UPDATE asset SET u_time = \?, click_count = click_count \+ 1 WHERE id = \?`). - WithArgs(sqlmock.AnyArg(), "1"). - WillReturnError(sql.ErrConnDone) - err = ctrl.IncreaseAssetClickCount(ctx, "1") - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} -func TestControllerDeleteAsset(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + var mAssetID int64 = 1 - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - mock.ExpectExec(`UPDATE asset SET u_time=\?,status=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), model.StatusDeleted, "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - err = ctrl.DeleteAsset(ctx, "1") - require.NoError(t, err) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("id = ?", mAssetID). + First(&model.Asset{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnError(gorm.ErrRecordNotFound) - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - err = ctrl.DeleteAsset(ctx, "1") + err := ctrl.DeleteAsset(ctx, "1") require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("UnexpectedUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + t.Run("Unauthorized", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "another-fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - err = ctrl.DeleteAsset(ctx, "1") - require.Error(t, err) - assert.ErrorIs(t, err, ErrForbidden) - }) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) - t.Run("NoAsset", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + mAsset := model.Asset{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID + 1, + Visibility: model.VisibilityPrivate, + } - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - err = ctrl.DeleteAsset(ctx, "1") + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("id = ?", mAsset.ID). + First(&model.Asset{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(assetDBColumns).AddRows(generateAssetDBRows(mAsset)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mAsset.OwnerID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(model.User{ + Model: model.Model{ID: mAsset.OwnerID}, + Username: "otheruser", + })...)) + + err := ctrl.DeleteAsset(ctx, "1") require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) + assert.ErrorIs(t, err, ErrForbidden) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + t.Run("InvalidID", func(t *testing.T) { + ctrl, _, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - mock.ExpectExec(`UPDATE asset SET u_time=\?,status=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), model.StatusDeleted, "1"). - WillReturnError(sql.ErrConnDone) - err = ctrl.DeleteAsset(ctx, "1") + + err := ctrl.DeleteAsset(ctx, "invalid") require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) + assert.EqualError(t, err, fmt.Sprintf("invalid asset id: %s", `strconv.ParseInt: parsing "invalid": invalid syntax`)) }) } diff --git a/spx-backend/internal/controller/controller.go b/spx-backend/internal/controller/controller.go index 801202ac6..08ad83dda 100644 --- a/spx-backend/internal/controller/controller.go +++ b/spx-backend/internal/controller/controller.go @@ -2,23 +2,27 @@ package controller import ( "context" - "database/sql" "errors" _ "image/png" "io/fs" "os" + "strconv" + "time" "github.com/casdoor/casdoor-go-sdk/casdoorsdk" _ "github.com/go-sql-driver/mysql" "github.com/goplus/builder/spx-backend/internal/aigc" "github.com/goplus/builder/spx-backend/internal/log" + "github.com/goplus/builder/spx-backend/internal/model" "github.com/joho/godotenv" _ "github.com/qiniu/go-cdk-driver/kodoblob" qiniuAuth "github.com/qiniu/go-sdk/v7/auth" qiniuLog "github.com/qiniu/x/log" + "gorm.io/gorm" ) var ( + ErrBadRequest = errors.New("bad request") ErrNotExist = errors.New("not exist") ErrUnauthorized = errors.New("unauthorized") ErrForbidden = errors.New("forbidden") @@ -32,7 +36,7 @@ type contextKey struct { // Controller is the controller for the service. type Controller struct { - db *sql.DB + db *gorm.DB kodo *kodoConfig aigcClient *aigc.AigcClient casdoorClient *casdoorsdk.Client @@ -48,34 +52,16 @@ func New(ctx context.Context) (*Controller, error) { } dsn := mustEnv(logger, "GOP_SPX_DSN") - db, err := sql.Open("mysql", dsn) + db, err := model.OpenDB(ctx, dsn, 0, 0) if err != nil { - logger.Printf("failed to connect sql: %v", err) + logger.Printf("failed to open database: %v", err) return nil, err } // TODO: Configure connection pool and timeouts. - kodoConfig := &kodoConfig{ - cred: qiniuAuth.New( - mustEnv(logger, "KODO_AK"), - mustEnv(logger, "KODO_SK"), - ), - bucket: mustEnv(logger, "KODO_BUCKET"), - bucketRegion: mustEnv(logger, "KODO_BUCKET_REGION"), - baseUrl: mustEnv(logger, "KODO_BASE_URL"), - } - + kodoConfig := newKodoConfig(logger) aigcClient := aigc.NewAigcClient(mustEnv(logger, "AIGC_ENDPOINT")) - - casdoorAuthConfig := &casdoorsdk.AuthConfig{ - Endpoint: os.Getenv("GOP_CASDOOR_ENDPOINT"), - ClientId: os.Getenv("GOP_CASDOOR_CLIENTID"), - ClientSecret: os.Getenv("GOP_CASDOOR_CLIENTSECRET"), - Certificate: os.Getenv("GOP_CASDOOR_CERTIFICATE"), - OrganizationName: os.Getenv("GOP_CASDOOR_ORGANIZATIONNAME"), - ApplicationName: os.Getenv("GOP_CASDOOR_APPLICATONNAME"), - } - casdoorClient := casdoorsdk.NewClientWithConf(casdoorAuthConfig) + casdoorClient := newCasdoorClient(logger) return &Controller{ db: db, @@ -93,6 +79,32 @@ type kodoConfig struct { baseUrl string } +// newKodoConfig creates a new [kodoConfig]. +func newKodoConfig(logger *qiniuLog.Logger) *kodoConfig { + return &kodoConfig{ + cred: qiniuAuth.New( + mustEnv(logger, "KODO_AK"), + mustEnv(logger, "KODO_SK"), + ), + bucket: mustEnv(logger, "KODO_BUCKET"), + bucketRegion: mustEnv(logger, "KODO_BUCKET_REGION"), + baseUrl: mustEnv(logger, "KODO_BASE_URL"), + } +} + +// newCasdoorClient creates a new [casdoorsdk.Client]. +func newCasdoorClient(logger *qiniuLog.Logger) *casdoorsdk.Client { + config := &casdoorsdk.AuthConfig{ + Endpoint: mustEnv(logger, "GOP_CASDOOR_ENDPOINT"), + ClientId: mustEnv(logger, "GOP_CASDOOR_CLIENTID"), + ClientSecret: mustEnv(logger, "GOP_CASDOOR_CLIENTSECRET"), + Certificate: mustEnv(logger, "GOP_CASDOOR_CERTIFICATE"), + OrganizationName: mustEnv(logger, "GOP_CASDOOR_ORGANIZATIONNAME"), + ApplicationName: mustEnv(logger, "GOP_CASDOOR_APPLICATIONNAME"), + } + return casdoorsdk.NewClientWithConf(config) +} + // mustEnv gets the environment variable value or exits the program. func mustEnv(logger *qiniuLog.Logger, key string) string { value := os.Getenv(key) @@ -101,3 +113,58 @@ func mustEnv(logger *qiniuLog.Logger, key string) string { } return value } + +// ModelDTO is the data transfer object for models. +type ModelDTO struct { + ID string `json:"id"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// toModelDTO converts the model to its DTO. +func toModelDTO(m model.Model) ModelDTO { + return ModelDTO{ + ID: strconv.FormatInt(m.ID, 10), + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } +} + +// SortOrder is the sort order. +type SortOrder string + +const ( + SortOrderAsc SortOrder = "asc" + SortOrderDesc SortOrder = "desc" +) + +// IsValid reports whether the sort order is valid. +func (so SortOrder) IsValid() bool { + switch so { + case SortOrderAsc, SortOrderDesc: + return true + } + return false +} + +// Pagination is the pagination information. +type Pagination struct { + Index int + Size int +} + +// IsValid reports whether the pagination is valid. +func (p Pagination) IsValid() bool { + return p.Index >= 1 && p.Size >= 1 && p.Size <= 100 +} + +// Offset returns the calculated offset for DB query. +func (p Pagination) Offset() int { + return (p.Index - 1) * p.Size +} + +// ByPage is a generic struct for paginated data. +type ByPage[T any] struct { + Total int64 `json:"total"` + Data []T `json:"data"` +} diff --git a/spx-backend/internal/controller/controller_test.go b/spx-backend/internal/controller/controller_test.go index 1dafeda6a..ec48e6e8d 100644 --- a/spx-backend/internal/controller/controller_test.go +++ b/spx-backend/internal/controller/controller_test.go @@ -1,10 +1,12 @@ package controller import ( - "context" "testing" "github.com/DATA-DOG/go-sqlmock" + "github.com/goplus/builder/spx-backend/internal/aigc" + "github.com/goplus/builder/spx-backend/internal/log" + "github.com/goplus/builder/spx-backend/internal/model/modeltest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -42,42 +44,69 @@ uPrfGhKFB+ckitTWslFGf1d/Dt/MYS544QlB06IW8f+AM7z0sohh5nGH8lQIOmLC VTh1XIl/IELBoZ+rQXozGA== -----END CERTIFICATE-----`) t.Setenv("GOP_CASDOOR_ORGANIZATIONNAME", "fake-organization") - t.Setenv("GOP_CASDOOR_APPLICATONNAME", "fake-application") + t.Setenv("GOP_CASDOOR_APPLICATIONNAME", "fake-application") } -func newTestController(t *testing.T) (*Controller, sqlmock.Sqlmock, error) { +func newTestController(t *testing.T) (ctrl *Controller, dbMock sqlmock.Sqlmock, closeDB func() error) { setTestEnv(t) - db, mock, err := sqlmock.New() - if err != nil { - return nil, nil, err - } - t.Cleanup(func() { - db.Close() + logger := log.GetLogger() + kodoConfig := newKodoConfig(logger) + aigcClient := aigc.NewAigcClient(mustEnv(logger, "AIGC_ENDPOINT")) + casdoorClient := newCasdoorClient(logger) + + db, dbMock, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + return &Controller{ + db: db, + kodo: kodoConfig, + aigcClient: aigcClient, + casdoorClient: casdoorClient, + }, dbMock, closeDB +} + +func TestSortOrder(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + assert.True(t, SortOrderAsc.IsValid()) + assert.True(t, SortOrderDesc.IsValid()) }) - ctrl, err := New(context.Background()) - if err != nil { - return nil, nil, err - } - ctrl.db = db - return ctrl, mock, nil + t.Run("Invalid", func(t *testing.T) { + assert.False(t, SortOrder("invalid").IsValid()) + }) } -func TestNew(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - setTestEnv(t) - ctrl, err := New(context.Background()) - require.NoError(t, err) - require.NotNil(t, ctrl) +func TestPagination(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + p := Pagination{Index: 1, Size: 20} + assert.True(t, p.IsValid()) + }) + + t.Run("InvalidIndex", func(t *testing.T) { + p := Pagination{Index: 0, Size: 20} + assert.False(t, p.IsValid()) + }) + + t.Run("InvalidSize", func(t *testing.T) { + p := Pagination{Index: 1, Size: 0} + assert.False(t, p.IsValid()) + }) + + t.Run("SizeExceedsMaximum", func(t *testing.T) { + p := Pagination{Index: 1, Size: 101} + assert.False(t, p.IsValid()) }) - t.Run("InvalidDSN", func(t *testing.T) { - setTestEnv(t) - t.Setenv("GOP_SPX_DSN", "invalid-dsn") - ctrl, err := New(context.Background()) - require.Error(t, err) - assert.EqualError(t, err, "invalid DSN: missing the slash separating the database name") - require.Nil(t, ctrl) + t.Run("Offset", func(t *testing.T) { + p := Pagination{Index: 3, Size: 20} + assert.Equal(t, 40, p.Offset()) }) } + +func stringPtr(s string) *string { + return &s +} + +func boolPtr(b bool) *bool { + return &b +} diff --git a/spx-backend/internal/controller/project.go b/spx-backend/internal/controller/project.go index 73792ebe7..4732b4daa 100644 --- a/spx-backend/internal/controller/project.go +++ b/spx-backend/internal/controller/project.go @@ -2,180 +2,761 @@ package controller import ( "context" + "database/sql" + "errors" + "fmt" + "maps" "regexp" + "strings" + "time" - "github.com/goplus/builder/spx-backend/internal/log" "github.com/goplus/builder/spx-backend/internal/model" + "gorm.io/gorm" ) -// projectNameRE is the regular expression for project name. -var projectNameRE = regexp.MustCompile(`^[\w-]{1,100}$`) +// ProjectDTO is the DTO for projects. +type ProjectDTO struct { + ModelDTO + + Owner string `json:"owner"` + RemixedFrom string `json:"remixedFrom,omitempty"` + Name string `json:"name"` + Version int `json:"version"` + Files model.FileCollection `json:"files"` + Visibility string `json:"visibility"` + Description string `json:"description"` + Instructions string `json:"instructions"` + Thumbnail string `json:"thumbnail"` + ViewCount int64 `json:"viewCount"` + LikeCount int64 `json:"likeCount"` + ReleaseCount int64 `json:"releaseCount"` + RemixCount int64 `json:"remixCount"` +} + +// toProjectDTO converts the model project to its DTO. +func toProjectDTO(mProject model.Project) ProjectDTO { + var remixedFrom string + if mProject.RemixedFromRelease != nil { + remixedFrom = fmt.Sprintf( + "%s/%s/%s", + mProject.RemixedFromRelease.Project.Owner.Username, + mProject.RemixedFromRelease.Project.Name, + mProject.RemixedFromRelease.Name, + ) + } + return ProjectDTO{ + ModelDTO: toModelDTO(mProject.Model), + Owner: mProject.Owner.Username, + RemixedFrom: remixedFrom, + Name: mProject.Name, + Version: mProject.Version, + Files: mProject.Files, + Visibility: mProject.Visibility.String(), + Description: mProject.Description, + Instructions: mProject.Instructions, + Thumbnail: mProject.Thumbnail, + ViewCount: mProject.ViewCount, + LikeCount: mProject.LikeCount, + ReleaseCount: mProject.ReleaseCount, + RemixCount: mProject.RemixCount, + } +} + +var ( + // projectNameRE is the regular expression for project name. + projectNameRE = regexp.MustCompile(`^[\w-]{1,100}$`) + + // projectFullNameRE is the regular expression for project full name. + projectFullNameRE = regexp.MustCompile(`^([\w-]{1,100})\/([\w-]{1,100})$`) +) // ensureProject ensures the project exists and the user has access to it. func (ctrl *Controller) ensureProject(ctx context.Context, owner, name string, ownedOnly bool) (*model.Project, error) { - logger := log.GetReqLogger(ctx) - - project, err := model.ProjectByOwnerAndName(ctx, ctrl.db, owner, name) - if err != nil { - logger.Printf("failed to get project %s/%s: %v", owner, name, err) - return nil, err + var mProject model.Project + if err := ctrl.db.WithContext(ctx). + Preload("Owner"). + Preload("RemixedFromRelease.Project.Owner"). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", owner). + Where("project.name = ?", name). + First(&mProject). + Error; err != nil { + return nil, fmt.Errorf("failed to get project %s/%s: %w", owner, name, err) } - if ownedOnly || project.IsPublic == model.Personal { - if _, err := EnsureUser(ctx, project.Owner); err != nil { + if ownedOnly || mProject.Visibility == model.VisibilityPrivate { + if _, err := ensureUser(ctx, mProject.OwnerID); err != nil { return nil, err } } - return project, nil + return &mProject, nil } -// GetProject gets project by owner and name. -func (ctrl *Controller) GetProject(ctx context.Context, owner, name string) (*model.Project, error) { - return ctrl.ensureProject(ctx, owner, name, false) +// CreateProjectParams holds parameters for creating project. +type CreateProjectParams struct { + RemixSource string `json:"remixSource"` + Name string `json:"name"` + Files model.FileCollection `json:"files"` + Visibility string `json:"visibility"` + Description string `json:"description"` + Instructions string `json:"instructions"` + Thumbnail string `json:"thumbnail"` +} + +// Validate validates the parameters. +func (p *CreateProjectParams) Validate() (ok bool, msg string) { + if p.RemixSource != "" && + !projectFullNameRE.MatchString(p.RemixSource) && + !projectReleaseFullNameRE.MatchString(p.RemixSource) { + return false, "invalid remixSource" + } + if p.Name == "" { + return false, "missing name" + } else if !projectNameRE.Match([]byte(p.Name)) { + return false, "invalid name" + } + if model.ParseVisibility(p.Visibility).String() != p.Visibility { + return false, "invalid visibility" + } + return true, "" +} + +// CreateProject creates a project. +func (ctrl *Controller) CreateProject(ctx context.Context, params *CreateProjectParams) (*ProjectDTO, error) { + mUser, ok := UserFromContext(ctx) + if !ok { + return nil, ErrUnauthorized + } + + mProject := model.Project{ + OwnerID: mUser.ID, + Name: params.Name, + Version: 1, + Files: params.Files, + Visibility: model.ParseVisibility(params.Visibility), + Description: params.Description, + Instructions: params.Instructions, + Thumbnail: params.Thumbnail, + } + if params.RemixSource != "" { + parts := strings.Split(params.RemixSource, "/") + ownerUsername := parts[0] + projectName := parts[1] + + var mRemixSourceProject model.Project + if err := ctrl.db.WithContext(ctx). + Preload("Owner"). + Preload("RemixedFromRelease.Project.Owner"). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", ownerUsername). + Where("project.name = ?", projectName). + First(&mRemixSourceProject). + Error; err != nil { + return nil, fmt.Errorf("failed to get project %s/%s: %w", ownerUsername, projectName, err) + } + if mRemixSourceProject.Visibility == model.VisibilityPrivate { + return nil, ErrNotExist + } + + releaseQuery := ctrl.db.WithContext(ctx). + Model(&model.ProjectRelease{}). + Where("project_id = ?", mRemixSourceProject.ID) + if len(parts) == 3 { + releaseName := parts[2] + releaseQuery = releaseQuery.Where("name = ?", releaseName) + } else { + releaseQuery = releaseQuery.Order("created_at DESC") // latest release + } + + var mRemixSourceProjectRelease model.ProjectRelease + if err := releaseQuery.First(&mRemixSourceProjectRelease).Error; err != nil { + return nil, fmt.Errorf("failed to get release of project %s/%s: %w", ownerUsername, projectName, err) + } + + mProject.RemixedFromReleaseID = sql.NullInt64{ + Int64: mRemixSourceProjectRelease.ID, + Valid: true, + } + } + if err := ctrl.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&mProject).Error; err != nil { + return err + } + + userUpdates := map[string]any{ + "project_count": gorm.Expr("project_count + 1"), + } + if mProject.Visibility == model.VisibilityPublic { + userUpdates["public_project_count"] = gorm.Expr("public_project_count + 1") + } + if err := tx.Model(mUser).Updates(userUpdates).Error; err != nil { + return err + } + + if mProject.RemixedFromReleaseID.Valid { + if err := tx. + Model(&model.ProjectRelease{}). + Where("id = ?", mProject.RemixedFromReleaseID.Int64). + Update("remix_count", gorm.Expr("remix_count + 1")). + Error; err != nil { + return err + } + if err := tx. + Model(&model.Project{}). + Joins("JOIN project_release ON project_release.project_id = project.id"). + Where("project_release.id = ?", mProject.RemixedFromReleaseID.Int64). + Update("project.remix_count", gorm.Expr("project.remix_count + 1")). + Error; err != nil { + return err + } + } + + return nil + }); err != nil { + return nil, fmt.Errorf("failed to create project: %w", err) + } + if err := ctrl.db.WithContext(ctx). + Preload("Owner"). + Preload("RemixedFromRelease.Project.Owner"). + First(&mProject). + Error; err != nil { + return nil, fmt.Errorf("failed to get project: %w", err) + } + projectDTO := toProjectDTO(mProject) + return &projectDTO, nil +} + +// ListProjectsOrderBy is the order by condition for listing projects. +type ListProjectsOrderBy string + +const ( + ListProjectsOrderByCreatedAt ListProjectsOrderBy = "createdAt" + ListProjectsOrderByUpdatedAt ListProjectsOrderBy = "updatedAt" + ListProjectsOrderByLikeCount ListProjectsOrderBy = "likeCount" + ListProjectsOrderByRemixCount ListProjectsOrderBy = "remixCount" + ListProjectsOrderByRecentLikeCount ListProjectsOrderBy = "recentLikeCount" + ListProjectsOrderByRecentRemixCount ListProjectsOrderBy = "recentRemixCount" +) + +// IsValid reports whether the order by condition is valid. +func (ob ListProjectsOrderBy) IsValid() bool { + switch ob { + case ListProjectsOrderByCreatedAt, + ListProjectsOrderByUpdatedAt, + ListProjectsOrderByLikeCount, + ListProjectsOrderByRemixCount, + ListProjectsOrderByRecentLikeCount, + ListProjectsOrderByRecentRemixCount: + return true + } + return false } // ListProjectsParams holds parameters for listing projects. type ListProjectsParams struct { - // Owner is the owner filter, applied only if non-nil. + // Owner filters projects by the owner's username. + // + // Applied only if non-nil. Owner *string - // IsPublic is the visibility filter, applied only if non-nil. - IsPublic *model.IsPublic + // RemixedFrom filters remixed projects by the full name of the source + // project or project release. + // + // Applied only if non-nil. + RemixedFrom *string + + // Keyword filters projects by name pattern. + // + // Applied only if non-nil. + Keyword *string + + // Visibility filters assets by visibility. + // + // Applied only if non-nil. + Visibility *string + + // Liked filters projects liked by the specified user. + // + // Applied only if non-nil. + Liker *string + + // CreatedAfter filters projects created after this timestamp. + // + // Applied only if non-nil. + CreatedAfter *time.Time + + // LikesReceivedAfter filters projects that gained new likes after this timestamp. + // + // Applied only if non-nil. + LikesReceivedAfter *time.Time + + // RemixesReceivedAfter filters projects that were remixed after this timestamp. + RemixesReceivedAfter *time.Time + + // FromFollowees indicates whether to include projects created by the + // authenticated user's followees. + // + // Applied only if non-nil. + FromFollowees *bool + + // OrderBy indicates the field by which to order the results. + OrderBy ListProjectsOrderBy + + // SortOrder indicates the order in which to sort the results. + SortOrder SortOrder // Pagination is the pagination information. - Pagination model.Pagination + Pagination Pagination +} + +// NewListProjectsParams creates a new ListProjectsParams. +func NewListProjectsParams() *ListProjectsParams { + return &ListProjectsParams{ + OrderBy: ListProjectsOrderByCreatedAt, + SortOrder: SortOrderDesc, + Pagination: Pagination{Index: 1, Size: 20}, + } } // Validate validates the parameters. func (p *ListProjectsParams) Validate() (ok bool, msg string) { + if p.RemixedFrom != nil && + !projectFullNameRE.MatchString(*p.RemixedFrom) && + !projectReleaseFullNameRE.MatchString(*p.RemixedFrom) { + return false, "invalid remixedFrom" + } + if p.Visibility != nil && model.ParseVisibility(*p.Visibility).String() != *p.Visibility { + return false, "invalid visibility" + } + if !p.OrderBy.IsValid() { + return false, "invalid orderBy" + } + if !p.SortOrder.IsValid() { + return false, "invalid sortOrder" + } + if !p.Pagination.IsValid() { + return false, "invalid pagination" + } return true, "" } // ListProjects lists projects. -func (ctrl *Controller) ListProjects(ctx context.Context, params *ListProjectsParams) (*model.ByPage[model.Project], error) { - logger := log.GetReqLogger(ctx) - +func (ctrl *Controller) ListProjects(ctx context.Context, params *ListProjectsParams) (*ByPage[ProjectDTO], error) { // Ensure non-owners can only see public projects. - if user, ok := UserFromContext(ctx); !ok || params.Owner == nil || user.Name != *params.Owner { - public := model.Public - params.IsPublic = &public + if mUser, ok := UserFromContext(ctx); !ok || params.Owner == nil || *params.Owner != mUser.Username { + public := model.VisibilityPublic.String() + params.Visibility = &public } - var wheres []model.FilterCondition + query := ctrl.db.WithContext(ctx). + Model(&model.Project{}). + Preload("Owner"). + Preload("RemixedFromRelease.Project.Owner") if params.Owner != nil { - wheres = append(wheres, model.FilterCondition{Column: "owner", Operation: "=", Value: *params.Owner}) + query = query.Joins("JOIN user ON user.id = project.owner_id").Where("user.username = ?", *params.Owner) } - if params.IsPublic != nil { - wheres = append(wheres, model.FilterCondition{Column: "is_public", Operation: "=", Value: *params.IsPublic}) + if params.RemixedFrom != nil { + parts := strings.Split(*params.RemixedFrom, "/") + ownerUsername := parts[0] + projectName := parts[1] + query = query. + Joins("JOIN project_release ON project_release.id = project.remixed_from_release_id"). + Joins("JOIN project AS remixed_from_project ON remixed_from_project.id = project_release.project_id"). + Joins("JOIN user AS remixed_from_user ON remixed_from_user.id = remixed_from_project.owner_id"). + Where("remixed_from_user.username = ?", ownerUsername). + Where("remixed_from_project.name = ?", projectName) + if len(parts) == 3 { + releaseName := parts[2] + query = query.Where("project_release.name = ?", releaseName) + } } - - projects, err := model.ListProjects(ctx, ctrl.db, params.Pagination, wheres, nil) - if err != nil { - logger.Printf("failed to list project: %v", err) - return nil, err + if params.Keyword != nil { + query = query.Where("project.name LIKE ?", "%"+*params.Keyword+"%") } - return projects, nil -} - -// AddProjectParams holds parameters for adding project. -type AddProjectParams struct { - Name string `json:"name"` - Owner string `json:"owner"` - Files model.FileCollection `json:"files"` - IsPublic model.IsPublic `json:"isPublic"` -} - -// Validate validates the parameters. -func (p *AddProjectParams) Validate() (ok bool, msg string) { - if p.Name == "" { - return false, "missing name" - } else if !projectNameRE.Match([]byte(p.Name)) { - return false, "invalid name" + if params.Visibility != nil { + query = query.Where("project.visibility = ?", model.ParseVisibility(*params.Visibility)) } - if p.Owner == "" { - return false, "missing owner" + if params.Liker != nil { + query = query. + Joins("JOIN user_project_relationship AS liker_relationship ON liker_relationship.project_id = project.id"). + Joins("JOIN user AS liker ON liker.id = liker_relationship.user_id"). + Where("liker.username = ?", *params.Liker). + Where("user_project_relationship.liked_at IS NOT NULL") } - switch p.IsPublic { - case model.Personal, model.Public: - default: - return false, "invalid isPublic" + if params.CreatedAfter != nil { + query = query.Where("project.created_at > ?", *params.CreatedAfter) + } + if params.LikesReceivedAfter != nil { + query = query. + Joins("JOIN user_project_relationship AS likes_after_relationship ON likes_after_relationship.project_id = project.id"). + Where("likes_after_relationship.liked_at > ?", *params.LikesReceivedAfter). + Group("project.id") + } + if params.RemixesReceivedAfter != nil { + query = query. + Joins("JOIN project_release ON project_release.project_id = project.id"). + Joins("JOIN project AS remixed_project ON remixed_project.remixed_from_release_id = project_release.id"). + Where("remixed_project.created_at > ?", *params.RemixesReceivedAfter). + Group("project.id") + } + if params.FromFollowees != nil && *params.FromFollowees { + mUser, ok := UserFromContext(ctx) + if !ok { + return nil, ErrUnauthorized + } + query = query. + Joins("JOIN user_relationship ON user_relationship.target_user_id = project.owner_id"). + Where("user_relationship.user_id = ?", mUser.ID). + Where("user_relationship.followed_at IS NOT NULL") + } + switch params.OrderBy { + case ListProjectsOrderByCreatedAt: + query = query.Order(fmt.Sprintf("project.created_at %s", params.SortOrder)) + case ListProjectsOrderByUpdatedAt: + query = query.Order(fmt.Sprintf("project.updated_at %s", params.SortOrder)) + case ListProjectsOrderByLikeCount: + query = query.Order(fmt.Sprintf("project.like_count %s", params.SortOrder)) + case ListProjectsOrderByRemixCount: + query = query.Order(fmt.Sprintf("project.remix_count %s", params.SortOrder)) + case ListProjectsOrderByRecentLikeCount: + if params.LikesReceivedAfter == nil { + query = query.Order(fmt.Sprintf("project.like_count %s", params.SortOrder)) + break + } + query = query. + Joins("JOIN user_project_relationship AS recent_likes_relationship ON recent_likes_relationship.project_id = project.id"). + Where("recent_likes_relationship.liked_at > ?", *params.LikesReceivedAfter). + Group("project.id"). + Order("COUNT(recent_likes_relationship.id) " + string(params.SortOrder)) + case ListProjectsOrderByRecentRemixCount: + if params.RemixesReceivedAfter == nil { + query = query.Order(fmt.Sprintf("project.remix_count %s", params.SortOrder)) + break + } + query = query. + Joins("JOIN project AS recent_remixed_project ON recent_remixed_project.remixed_from_release_id = project_release.id"). + Where("recent_remixed_project.created_at > ?", *params.RemixesReceivedAfter). + Group("project.id"). + Order("COUNT(recent_remixed_project.id) " + string(params.SortOrder)) } - return true, "" -} -// AddProject adds a project. -func (ctrl *Controller) AddProject(ctx context.Context, params *AddProjectParams) (*model.Project, error) { - logger := log.GetReqLogger(ctx) + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, fmt.Errorf("failed to count projects: %w", err) + } - user, err := EnsureUser(ctx, params.Owner) - if err != nil { - return nil, err + var mProjects []model.Project + if err := query. + Preload("Owner"). + Preload("RemixedFromRelease.Project.Owner"). + Offset(params.Pagination.Offset()). + Limit(params.Pagination.Size). + Find(&mProjects). + Error; err != nil { + return nil, fmt.Errorf("failed to list projects: %w", err) } + projectDTOs := make([]ProjectDTO, len(mProjects)) + for i, mProject := range mProjects { + projectDTOs[i] = toProjectDTO(mProject) + } + return &ByPage[ProjectDTO]{ + Total: total, + Data: projectDTOs, + }, nil +} - project, err := model.AddProject(ctx, ctrl.db, &model.Project{ - Name: params.Name, - Owner: user.Name, - Version: 1, - Files: params.Files, - IsPublic: params.IsPublic, - }) +// GetProject gets project by owner and name. +func (ctrl *Controller) GetProject(ctx context.Context, owner, name string) (*ProjectDTO, error) { + mProject, err := ctrl.ensureProject(ctx, owner, name, false) if err != nil { - logger.Printf("failed to add project: %v", err) return nil, err } - return project, nil + projectDTO := toProjectDTO(*mProject) + return &projectDTO, nil } // UpdateProjectParams holds parameters for updating project. type UpdateProjectParams struct { - Files model.FileCollection `json:"files"` - IsPublic model.IsPublic `json:"isPublic"` + Files model.FileCollection `json:"files"` + Visibility string `json:"visibility"` + Description string `json:"description"` + Instructions string `json:"instructions"` + Thumbnail string `json:"thumbnail"` } // Validate validates the parameters. func (p *UpdateProjectParams) Validate() (ok bool, msg string) { - switch p.IsPublic { - case model.Personal, model.Public: - default: - return false, "invalid isPublic" + if model.ParseVisibility(p.Visibility).String() != p.Visibility { + return false, "invalid visibility" } return true, "" } // UpdateProject updates a project. -func (ctrl *Controller) UpdateProject(ctx context.Context, owner, name string, params *UpdateProjectParams) (*model.Project, error) { - logger := log.GetReqLogger(ctx) +func (ctrl *Controller) UpdateProject(ctx context.Context, owner, name string, params *UpdateProjectParams) (*ProjectDTO, error) { + mUser, ok := UserFromContext(ctx) + if !ok { + return nil, ErrUnauthorized + } - project, err := ctrl.ensureProject(ctx, owner, name, true) + mProject, err := ctrl.ensureProject(ctx, owner, name, true) if err != nil { return nil, err } + updates := map[string]any{} + if !maps.Equal(params.Files, mProject.Files) { + updates["files"] = params.Files + } + if params.Visibility != mProject.Visibility.String() { + updates["visibility"] = model.ParseVisibility(params.Visibility) + } + if params.Description != mProject.Description { + updates["description"] = params.Description + } + if params.Instructions != mProject.Instructions { + updates["instructions"] = params.Instructions + } + if params.Thumbnail != mProject.Thumbnail { + updates["thumbnail"] = params.Thumbnail + } + if len(updates) > 0 { + if err := ctrl.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if queryResult := tx.Model(mProject).Omit("Owner", "RemixedFromRelease").Updates(updates); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return nil + } - updatedProject, err := model.UpdateProjectByID(ctx, ctrl.db, project.ID, &model.Project{ - Version: project.Version + 1, - Files: params.Files, - IsPublic: params.IsPublic, - }) - if err != nil { - logger.Printf("failed to update project: %v", err) - return nil, err + userUpdates := map[string]any{} + if updates["visibility"] != nil { + switch params.Visibility { + case model.VisibilityPrivate.String(): + userUpdates["public_project_count"] = gorm.Expr("public_project_count - 1") + case model.VisibilityPublic.String(): + userUpdates["public_project_count"] = gorm.Expr("public_project_count + 1") + } + } + if len(userUpdates) > 0 { + if err := tx.Model(mUser).Updates(userUpdates).Error; err != nil { + return err + } + } + + return nil + }); err != nil { + return nil, fmt.Errorf("failed to update project: %w", err) + } } - return updatedProject, nil + projectDTO := toProjectDTO(*mProject) + return &projectDTO, nil } // DeleteProject deletes a project. func (ctrl *Controller) DeleteProject(ctx context.Context, owner, name string) error { - logger := log.GetReqLogger(ctx) + mUser, ok := UserFromContext(ctx) + if !ok { + return ErrUnauthorized + } + + mProject, err := ctrl.ensureProject(ctx, owner, name, true) + if err != nil { + return err + } + if err := ctrl.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Delete(mProject).Error; err != nil { + return err + } + + userUpdates := map[string]any{ + "project_count": gorm.Expr("project_count - 1"), + } + if mProject.Visibility == model.VisibilityPublic { + userUpdates["public_project_count"] = gorm.Expr("public_project_count - 1") + } + if err := tx.Model(mUser).Updates(userUpdates).Error; err != nil { + return err + } + + if mProject.RemixedFromReleaseID.Valid { + if err := tx. + Model(&model.ProjectRelease{}). + Where("id = ?", mProject.RemixedFromReleaseID.Int64). + Update("remix_count", gorm.Expr("remix_count - 1")). + Error; err != nil { + return err + } + if err := tx. + Model(&model.Project{}). + Joins("JOIN project_release ON project_release.project_id = project.id"). + Where("project_release.id = ?", mProject.RemixedFromReleaseID.Int64). + Update("project.remix_count", gorm.Expr("project.remix_count - 1")). + Error; err != nil { + return err + } + } - project, err := ctrl.ensureProject(ctx, owner, name, true) + if err := tx. + Model(&model.User{}). + Joins("JOIN user_project_relationship ON user_project_relationship.user_id = user.id"). + Where("user_project_relationship.project_id = ?", mProject.ID). + Where("user_project_relationship.liked_at IS NOT NULL"). + Update("user.liked_project_count", gorm.Expr("user.liked_project_count - 1")). + Error; err != nil { + return err + } + if err := tx. + Where("project_id = ?", mProject.ID). + Delete(&model.UserProjectRelationship{}). + Error; err != nil { + return err + } + + if err := tx. + Model(&model.Project{}). + Joins("JOIN project_release ON project_release.id = project.remixed_from_release_id"). + Where("project_release.project_id = ?", mProject.ID). + Update("project.remixed_from_release_id", sql.NullInt64{}). + Error; err != nil { + return err + } + if err := tx. + Where("project_id = ?", mProject.ID). + Delete(&model.ProjectRelease{}). + Error; err != nil { + return err + } + + return nil + }); err != nil { + return fmt.Errorf("failed to delete project: %w", err) + } + return nil +} + +// LikeProject likes the specified project as the authenticated user. +func (ctrl *Controller) LikeProject(ctx context.Context, owner, name string) error { + mUser, ok := UserFromContext(ctx) + if !ok { + return ErrUnauthorized + } + + var mProject model.Project + if err := ctrl.db.WithContext(ctx). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", owner). + Where("project.name = ?", name). + First(&mProject). + Error; err != nil { + return fmt.Errorf("failed to get project %s/%s: %w", owner, name, err) + } + + mUserProjectRelationship, err := model.FirstOrCreateUserProjectRelationship(ctx, ctrl.db, mUser.ID, mProject.ID) if err != nil { return err } + if mUserProjectRelationship.LikedAt.Valid { + return nil + } - if err := model.DeleteProjectByID(ctx, ctrl.db, project.ID); err != nil { - logger.Printf("failed to delete project: %v", err) + if err := ctrl.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if queryResult := tx. + Model(mUserProjectRelationship). + Update("liked_at", sql.NullTime{ + Time: time.Now().UTC(), + Valid: true, + }); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return nil + } + if err := tx.Model(mUser).Update("liked_project_count", gorm.Expr("liked_project_count + 1")).Error; err != nil { + return err + } + if err := tx.Model(&mProject).Update("like_count", gorm.Expr("like_count + 1")).Error; err != nil { + return err + } + return nil + }); err != nil { + return fmt.Errorf("failed to like project: %w", err) + } + return nil +} + +// HasLikedProject checks if the authenticated user has liked the specified project. +func (ctrl *Controller) HasLikedProject(ctx context.Context, owner, name string) (bool, error) { + mUser, ok := UserFromContext(ctx) + if !ok { + return false, nil + } + + var mProject model.Project + if err := ctrl.db.WithContext(ctx). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", owner). + Where("project.name = ?", name). + First(&mProject). + Error; err != nil { + return false, fmt.Errorf("failed to get project %s/%s: %w", owner, name, err) + } + + if err := ctrl.db.WithContext(ctx). + Select("id"). + Where("user_id = ?", mUser.ID). + Where("project_id = ?", mProject.ID). + Where("liked_at IS NOT NULL"). + First(&model.UserProjectRelationship{}). + Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, fmt.Errorf("failed to check if user %s has liked project %s/%s: %w", mUser.Username, owner, name, err) + } + return true, nil +} + +// UnlikeProject unlikes the specified project as the authenticated user. +func (ctrl *Controller) UnlikeProject(ctx context.Context, owner, name string) error { + mUser, ok := UserFromContext(ctx) + if !ok { + return ErrUnauthorized + } + + var mProject model.Project + if err := ctrl.db.WithContext(ctx). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", owner). + Where("project.name = ?", name). + First(&mProject). + Error; err != nil { + return fmt.Errorf("failed to get project %s/%s: %w", owner, name, err) + } + + mUserProjectRelationship, err := model.FirstOrCreateUserProjectRelationship(ctx, ctrl.db, mUser.ID, mProject.ID) + if err != nil { return err } + if !mUserProjectRelationship.LikedAt.Valid { + return nil + } + + if err := ctrl.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if queryResult := tx. + Model(mUserProjectRelationship). + Update("liked_at", sql.NullTime{}); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return nil + } + if err := tx.Model(mUser).Update("liked_project_count", gorm.Expr("liked_project_count - 1")).Error; err != nil { + return err + } + if err := tx.Model(&mProject).Update("like_count", gorm.Expr("like_count - 1")).Error; err != nil { + return err + } + return nil + }); err != nil { + return fmt.Errorf("failed to unlike project: %w", err) + } return nil } diff --git a/spx-backend/internal/controller/project_release.go b/spx-backend/internal/controller/project_release.go new file mode 100644 index 000000000..4ba9b6bbd --- /dev/null +++ b/spx-backend/internal/controller/project_release.go @@ -0,0 +1,249 @@ +package controller + +import ( + "context" + "fmt" + "regexp" + + "github.com/goplus/builder/spx-backend/internal/model" + "gorm.io/gorm" +) + +// ProjectReleaseDTO is the DTO for project releases. +type ProjectReleaseDTO struct { + ModelDTO + + ProjectFullName string `json:"projectFullName"` + Name string `json:"name"` + Description string `json:"description"` + Files model.FileCollection `json:"files"` + Thumbnail string `json:"thumbnail"` + RemixCount int64 `json:"remixCount"` +} + +// toProjectReleaseDTO converts the model project release to its DTO. +func toProjectReleaseDTO(pr model.ProjectRelease) ProjectReleaseDTO { + return ProjectReleaseDTO{ + ModelDTO: toModelDTO(pr.Model), + ProjectFullName: fmt.Sprintf( + "%s/%s", + pr.Project.Owner.Username, + pr.Project.Name, + ), + Name: pr.Name, + Description: pr.Description, + Files: pr.Files, + Thumbnail: pr.Thumbnail, + RemixCount: pr.RemixCount, + } +} + +var ( + // projectReleaseNameRE is the regular expression for project release name. + projectReleaseNameRE = regexp.MustCompile(`^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`) + + // projectReleaseFullNameRE is the regular expression for project release full name. + projectReleaseFullNameRE = regexp.MustCompile(`^([\w-]{1,100})\/([\w-]{1,100})\/(v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)))?$`) +) + +// CreateProjectReleaseParams holds the parameters for creating a project release. +type CreateProjectReleaseParams struct { + ProjectFullName string `json:"projectFullName"` + Name string `json:"name"` + Description string `json:"description"` + Thumbnail string `json:"thumbnail"` +} + +// Validate validates the parameters. +func (p *CreateProjectReleaseParams) Validate() (ok bool, msg string) { + if !projectFullNameRE.MatchString(p.ProjectFullName) { + return false, "invalid projectFullName" + } + if !projectReleaseNameRE.MatchString(p.Name) { + return false, "invalid projectReleaseName" + } + return true, "" +} + +// CreateProjectRelease creates a project release. +func (ctrl *Controller) CreateProjectRelease(ctx context.Context, params *CreateProjectReleaseParams) (*ProjectReleaseDTO, error) { + projectFullNameMatches := projectFullNameRE.FindStringSubmatch(params.ProjectFullName) + projectOwnerUsername := projectFullNameMatches[1] + projectName := projectFullNameMatches[2] + + mProject, err := ctrl.ensureProject(ctx, projectOwnerUsername, projectName, true) + if err != nil { + return nil, err + } + + if params.Description == "" { + params.Description = mProject.Description + } + if params.Thumbnail == "" { + params.Thumbnail = mProject.Thumbnail + } + mProjectRelease := model.ProjectRelease{ + ProjectID: mProject.ID, + Name: params.Name, + Description: params.Description, + Files: mProject.Files, + Thumbnail: params.Thumbnail, + } + if err := ctrl.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&mProjectRelease).Error; err != nil { + return err + } + + if err := tx. + Model(&mProject). + Omit("Owner", "RemixedFromRelease"). + Update("release_count", gorm.Expr("release_count + 1")). + Error; err != nil { + return err + } + + return nil + }); err != nil { + return nil, fmt.Errorf("failed to create project release: %w", err) + } + if err := ctrl.db.WithContext(ctx). + Preload("Project.Owner"). + First(&mProjectRelease). + Error; err != nil { + return nil, fmt.Errorf("failed to get project release: %w", err) + } + projectReleaseDTO := toProjectReleaseDTO(mProjectRelease) + return &projectReleaseDTO, nil +} + +// ListProjectReleasesOrderBy is the order by for listing project releases. +type ListProjectReleasesOrderBy string + +const ( + ListProjectReleasesOrderByCreatedAt ListProjectReleasesOrderBy = "createdAt" + ListProjectReleasesOrderByUpdatedAt ListProjectReleasesOrderBy = "updatedAt" + ListProjectReleasesOrderByRemixCount ListProjectReleasesOrderBy = "remixCount" +) + +// IsValid reports whether the order by condition is valid. +func (ob ListProjectReleasesOrderBy) IsValid() bool { + switch ob { + case ListProjectReleasesOrderByCreatedAt, + ListProjectReleasesOrderByUpdatedAt, + ListProjectReleasesOrderByRemixCount: + return true + } + return false +} + +// ListProjectReleasesParams holds the parameters for listing project releases. +type ListProjectReleasesParams struct { + // ProjectFullName filters releases by the full name of the associated project. + // + // Applied only if non-nil. + ProjectFullName *string + + // OrderBy indicates the field by which to order the results. + OrderBy ListProjectReleasesOrderBy + + // SortOrder indicates the order in which to sort the results. + SortOrder SortOrder + + // Pagination is the pagination information. + Pagination Pagination +} + +// NewListProjectReleasesParams creates a new ListProjectReleasesParams. +func NewListProjectReleasesParams() *ListProjectReleasesParams { + return &ListProjectReleasesParams{ + OrderBy: ListProjectReleasesOrderByCreatedAt, + SortOrder: SortOrderDesc, + Pagination: Pagination{Index: 1, Size: 20}, + } +} + +// Validate validates the parameters. +func (p *ListProjectReleasesParams) Validate() (ok bool, msg string) { + if p.ProjectFullName != nil && !projectFullNameRE.MatchString(*p.ProjectFullName) { + return false, "invalid projectFullName" + } + if !p.OrderBy.IsValid() { + return false, "invalid orderBy" + } + if !p.SortOrder.IsValid() { + return false, "invalid sortOrder" + } + if !p.Pagination.IsValid() { + return false, "invalid pagination" + } + return true, "" +} + +// ListProjectReleases lists project releases. +func (ctrl *Controller) ListProjectReleases(ctx context.Context, params *ListProjectReleasesParams) (*ByPage[ProjectReleaseDTO], error) { + query := ctrl.db.WithContext(ctx). + Model(&model.ProjectRelease{}). + Preload("Project.Owner"). + Joins("JOIN project ON project.id = project_release.project_id"). + Where("project.visibility = ?", model.VisibilityPublic) + if params.ProjectFullName != nil { + projectFullNameMatches := projectFullNameRE.FindStringSubmatch(*params.ProjectFullName) + projectOwnerUsername := projectFullNameMatches[1] + projectName := projectFullNameMatches[2] + query = query. + Joins("JOIN user AS project_owner ON project_owner.id = project.owner_id"). + Where("project_owner.username = ?", projectOwnerUsername). + Where("project.name = ?", projectName) + } + switch params.OrderBy { + case ListProjectReleasesOrderByCreatedAt: + query = query.Order(fmt.Sprintf("project_release.created_at %s", params.SortOrder)) + case ListProjectReleasesOrderByUpdatedAt: + query = query.Order(fmt.Sprintf("project_release.updated_at %s", params.SortOrder)) + case ListProjectReleasesOrderByRemixCount: + query = query.Order(fmt.Sprintf("project_release.remix_count %s", params.SortOrder)) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, fmt.Errorf("failed to count project releases: %w", err) + } + + var mProjectReleases []model.ProjectRelease + if err := query. + Preload("Project.Owner"). + Offset(params.Pagination.Offset()). + Limit(params.Pagination.Size). + Find(&mProjectReleases). + Error; err != nil { + return nil, fmt.Errorf("failed to list project releases: %w", err) + } + projectReleaseDTOs := make([]ProjectReleaseDTO, len(mProjectReleases)) + for i, mProjectRelease := range mProjectReleases { + projectReleaseDTOs[i] = toProjectReleaseDTO(mProjectRelease) + } + return &ByPage[ProjectReleaseDTO]{ + Total: total, + Data: projectReleaseDTOs, + }, nil +} + +// GetProjectRelease gets a project release. +func (ctrl *Controller) GetProjectRelease(ctx context.Context, projectOwnerUsername, projectName, projectReleaseName string) (*ProjectReleaseDTO, error) { + mProject, err := ctrl.ensureProject(ctx, projectOwnerUsername, projectName, false) + if err != nil { + return nil, err + } + + var mProjectRelease model.ProjectRelease + if err := ctrl.db.WithContext(ctx). + Preload("Project.Owner"). + Where("project_id = ?", mProject.ID). + Where("name = ?", projectReleaseName). + First(&mProjectRelease). + Error; err != nil { + return nil, fmt.Errorf("failed to get project release: %w", err) + } + projectReleaseDTO := toProjectReleaseDTO(mProjectRelease) + return &projectReleaseDTO, nil +} diff --git a/spx-backend/internal/controller/project_release_test.go b/spx-backend/internal/controller/project_release_test.go new file mode 100644 index 000000000..18a4afa45 --- /dev/null +++ b/spx-backend/internal/controller/project_release_test.go @@ -0,0 +1,430 @@ +package controller + +import ( + "context" + "fmt" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/goplus/builder/spx-backend/internal/model" + "github.com/goplus/builder/spx-backend/internal/model/modeltest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestCreateProjectReleaseParams(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + params := &CreateProjectReleaseParams{ + ProjectFullName: "user/project", + Name: "v1.0.0", + Description: "First release", + Thumbnail: "http://example.com/thumbnail.jpg", + } + ok, msg := params.Validate() + assert.True(t, ok) + assert.Empty(t, msg) + }) + + t.Run("InvalidProjectFullName", func(t *testing.T) { + params := &CreateProjectReleaseParams{ + ProjectFullName: "invalid/project/name", + Name: "v1.0.0", + } + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "invalid projectFullName", msg) + }) + + t.Run("InvalidReleaseName", func(t *testing.T) { + params := &CreateProjectReleaseParams{ + ProjectFullName: "user/project", + Name: "invalid-version", + } + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "invalid projectReleaseName", msg) + }) +} + +func TestControllerCreateProjectRelease(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + projectDBColumns, err := modeltest.ExtractDBColumns(db, model.Project{}) + require.NoError(t, err) + generateProjectDBRows, err := modeltest.NewDBRowsGenerator(db, model.Project{}) + require.NoError(t, err) + projectReleaseDBColumns, err := modeltest.ExtractDBColumns(db, model.ProjectRelease{}) + require.NoError(t, err) + generateProjectReleaseDBRows, err := modeltest.NewDBRowsGenerator(db, model.ProjectRelease{}) + require.NoError(t, err) + + t.Run("Normal", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID, + Name: "testproject", + Description: "Test project", + Thumbnail: "http://example.com/project-thumbnail.jpg", + } + + params := &CreateProjectReleaseParams{ + ProjectFullName: fmt.Sprintf("%s/%s", mUser.Username, mProject.Name), + Name: "v1.0.0", + Description: "Test release", + Thumbnail: "http://example.com/thumbnail.jpg", + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mUser.Username). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + dbMock.ExpectBegin() + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Create(&model.ProjectRelease{}). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnResult(sqlmock.NewResult(1, 1)) + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.Project{Model: mProject.Model}). + Update("release_count", gorm.Expr("release_count + 1")). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnResult(sqlmock.NewResult(0, 1)) + dbMock.ExpectCommit() + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + First(&model.ProjectRelease{Model: model.Model{ID: 1}}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectReleaseDBColumns).AddRows(generateProjectReleaseDBRows(model.ProjectRelease{ + Model: model.Model{ID: 1}, + ProjectID: mProject.ID, + Name: params.Name, + Description: params.Description, + Thumbnail: params.Thumbnail, + Project: mProject, + })...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`project`.`id` = ?", mProject.ID). + Find(&model.Project{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + releaseDTO, err := ctrl.CreateProjectRelease(ctx, params) + require.NoError(t, err) + assert.Equal(t, params.ProjectFullName, releaseDTO.ProjectFullName) + assert.Equal(t, params.Name, releaseDTO.Name) + assert.Equal(t, params.Description, releaseDTO.Description) + assert.Equal(t, params.Thumbnail, releaseDTO.Thumbnail) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("ProjectNotFound", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjectName := "nonexistent" + + params := &CreateProjectReleaseParams{ + ProjectFullName: fmt.Sprintf("%s/%s", mUser.Username, mProjectName), + Name: "v1.0.0", + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mUser.Username). + Where("project.name = ?", mProjectName). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns)) + + _, err := ctrl.CreateProjectRelease(ctx, params) + require.Error(t, err) + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) +} + +func TestListProjectReleasesOrderBy(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + assert.True(t, ListProjectReleasesOrderByCreatedAt.IsValid()) + assert.True(t, ListProjectReleasesOrderByUpdatedAt.IsValid()) + assert.True(t, ListProjectReleasesOrderByRemixCount.IsValid()) + }) + + t.Run("Invalid", func(t *testing.T) { + assert.False(t, ListProjectReleasesOrderBy("invalid").IsValid()) + }) +} + +func TestListProjectReleasesParams(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + params := NewListProjectReleasesParams() + ok, msg := params.Validate() + assert.True(t, ok) + assert.Empty(t, msg) + }) + + t.Run("InvalidProjectFullName", func(t *testing.T) { + params := NewListProjectReleasesParams() + invalidName := "invalid/project/name" + params.ProjectFullName = &invalidName + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "invalid projectFullName", msg) + }) + + t.Run("InvalidOrderBy", func(t *testing.T) { + params := NewListProjectReleasesParams() + params.OrderBy = ListProjectReleasesOrderBy("invalid") + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "invalid orderBy", msg) + }) + + t.Run("InvalidSortOrder", func(t *testing.T) { + params := NewListProjectReleasesParams() + params.SortOrder = SortOrder("invalid") + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "invalid sortOrder", msg) + }) + + t.Run("InvalidPagination", func(t *testing.T) { + params := NewListProjectReleasesParams() + params.Pagination.Index = 0 + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "invalid pagination", msg) + }) +} + +func TestControllerGetProjectRelease(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + projectDBColumns, err := modeltest.ExtractDBColumns(db, model.Project{}) + require.NoError(t, err) + generateProjectDBRows, err := modeltest.NewDBRowsGenerator(db, model.Project{}) + require.NoError(t, err) + projectReleaseDBColumns, err := modeltest.ExtractDBColumns(db, model.ProjectRelease{}) + require.NoError(t, err) + generateProjectReleaseDBRows, err := modeltest.NewDBRowsGenerator(db, model.ProjectRelease{}) + require.NoError(t, err) + + t.Run("Normal", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: 1, + Name: "testproject", + Visibility: model.VisibilityPublic, + } + + mProjectRelease := model.ProjectRelease{ + Model: model.Model{ID: 1}, + ProjectID: mProject.ID, + Name: "v1.0.0", + Project: mProject, + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mUser.Username). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("project_id = ?", mProject.ID). + Where("name = ?", mProjectRelease.Name). + First(&model.ProjectRelease{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectReleaseDBColumns).AddRows(generateProjectReleaseDBRows(mProjectRelease)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`project`.`id` = ?", mProject.ID). + Find(&model.Project{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + projectReleaseDTO, err := ctrl.GetProjectRelease(ctx, mUser.Username, "testproject", "v1.0.0") + require.NoError(t, err) + assert.Equal(t, mUser.Username+"/testproject", projectReleaseDTO.ProjectFullName) + assert.Equal(t, "v1.0.0", projectReleaseDTO.Name) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("ProjectNotFound", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjectName := "nonexistent" + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mUser.Username). + Where("project.name = ?", mProjectName). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns)) + + _, err := ctrl.GetProjectRelease(ctx, mUser.Username, mProjectName, "v1.0.0") + require.Error(t, err) + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("ReleaseNotFound", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: 1, + Name: "testproject", + Visibility: model.VisibilityPublic, + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mUser.Username). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("project_id = ?", mProject.ID). + Where("name = ?", "v1.0.0"). + First(&model.ProjectRelease{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectReleaseDBColumns)) + + _, err := ctrl.GetProjectRelease(ctx, mUser.Username, mProject.Name, "v1.0.0") + require.Error(t, err) + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) +} diff --git a/spx-backend/internal/controller/project_test.go b/spx-backend/internal/controller/project_test.go index 8a1a00cc6..b088d6dc4 100644 --- a/spx-backend/internal/controller/project_test.go +++ b/spx-backend/internal/controller/project_test.go @@ -3,606 +3,2173 @@ package controller import ( "context" "database/sql" + "errors" + "fmt" + "regexp" "testing" + "time" "github.com/DATA-DOG/go-sqlmock" "github.com/goplus/builder/spx-backend/internal/model" + "github.com/goplus/builder/spx-backend/internal/model/modeltest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) func TestControllerEnsureProject(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + projectDBColumns, err := modeltest.ExtractDBColumns(db, model.Project{}) + require.NoError(t, err) + generateProjectDBRows, err := modeltest.NewDBRowsGenerator(db, model.Project{}) + require.NoError(t, err) + t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - project, err := ctrl.ensureProject(ctx, "fake-name", "fake-project", false) - require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "1", project.ID) - }) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID, + Name: "testproject", + Visibility: model.VisibilityPublic, + } - t.Run("NoProject", func(t *testing.T) { - ctrl, mock, err := newTestController(t) + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mUser.Username). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + project, err := ctrl.ensureProject(ctx, mUser.Username, mProject.Name, false) require.NoError(t, err) + assert.Equal(t, mProject.ID, project.ID) + assert.Equal(t, mProject.Name, project.Name) - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - _, err = ctrl.ensureProject(ctx, "fake-name", "fake-project", false) - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + t.Run("ProjectNotFound", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - _, err = ctrl.ensureProject(ctx, "fake-name", "fake-project", false) + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjectName := "nonexistent" + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mUser.Username). + Where("project.name = ?", mProjectName). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnError(gorm.ErrRecordNotFound) + + _, err := ctrl.ensureProject(ctx, mUser.Username, mProjectName, false) require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) - t.Run("NoUserWithPublicProject", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner", "is_public"}). - AddRow(1, "fake-project", "fake-name", model.Public)) - project, err := ctrl.ensureProject(ctx, "fake-name", "fake-project", false) - require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "1", project.ID) + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("NoUserWithPublicProjectButCheckOwner", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + t.Run("Unauthorized", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner", "is_public"}). - AddRow(1, "fake-project", "fake-name", model.Public)) - _, err = ctrl.ensureProject(ctx, "fake-name", "fake-project", true) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) - t.Run("NoUserWithPersonalProject", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + mProjectOwnerUsername := "otheruser" - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner", "is_public"}). - AddRow(1, "fake-project", "fake-name", model.Personal)) - _, err = ctrl.ensureProject(ctx, "fake-name", "fake-project", false) + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID + 1, + Name: "privateproject", + Visibility: model.VisibilityPrivate, + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mProjectOwnerUsername). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mProject.OwnerID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(model.User{ + Model: model.Model{ID: mProject.OwnerID}, + Username: mProjectOwnerUsername, + })...)) + + _, err := ctrl.ensureProject(ctx, mProjectOwnerUsername, mProject.Name, false) require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) + assert.ErrorIs(t, err, ErrForbidden) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) -} -func TestControllerGetProject(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + t.Run("WithNonOwner", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - project, err := ctrl.GetProject(ctx, "fake-name", "fake-project") - require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "1", project.ID) - }) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) - t.Run("NoProject", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + mProjectOwnerUsername := "otheruser" - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - _, err = ctrl.GetProject(ctx, "fake-name", "fake-project") + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID + 1, + Name: "publicproject", + Visibility: model.VisibilityPublic, + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mProjectOwnerUsername). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mProject.OwnerID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(model.User{ + Model: model.Model{ID: mProject.OwnerID}, + Username: mProjectOwnerUsername, + })...)) + + _, err := ctrl.ensureProject(ctx, mProjectOwnerUsername, mProject.Name, true) require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) + assert.ErrorIs(t, err, ErrForbidden) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) } -func TestListProjectsParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - paramsOwner := "fake-name" - paramsIsPublic := model.Personal - params := &ListProjectsParams{ - Owner: ¶msOwner, - IsPublic: ¶msIsPublic, - Pagination: model.Pagination{Index: 1, Size: 10}, +func TestCreateProjectParams(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + params := &CreateProjectParams{ + RemixSource: "user/project", + Name: "testproject", + Files: model.FileCollection{ + "main.go": "http://example.com/main.go", + }, + Visibility: "public", + Description: "Test project description", + Instructions: "How to use this project", + Thumbnail: "http://example.com/thumbnail.jpg", } ok, msg := params.Validate() assert.True(t, ok) assert.Empty(t, msg) }) + + t.Run("ValidWithoutRemixSource", func(t *testing.T) { + params := &CreateProjectParams{ + Name: "testproject", + Files: model.FileCollection{}, + Visibility: "private", + Description: "Test project description", + Instructions: "How to use this project", + Thumbnail: "http://example.com/thumbnail.jpg", + } + ok, msg := params.Validate() + assert.True(t, ok) + assert.Empty(t, msg) + }) + + t.Run("InvalidRemixSource", func(t *testing.T) { + params := &CreateProjectParams{ + RemixSource: "invalid/project/name", + Name: "testproject", + Visibility: "public", + } + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "invalid remixSource", msg) + }) + + t.Run("MissingName", func(t *testing.T) { + params := &CreateProjectParams{ + Visibility: "public", + } + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "missing name", msg) + }) + + t.Run("InvalidName", func(t *testing.T) { + params := &CreateProjectParams{ + Name: "invalid project name", + Visibility: "public", + } + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "invalid name", msg) + }) + + t.Run("InvalidVisibility", func(t *testing.T) { + params := &CreateProjectParams{ + Name: "testproject", + Visibility: "invalid", + } + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "invalid visibility", msg) + }) } -func TestControllerListProjects(t *testing.T) { +func TestControllerCreateProject(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + projectDBColumns, err := modeltest.ExtractDBColumns(db, model.Project{}) + require.NoError(t, err) + generateProjectDBRows, err := modeltest.NewDBRowsGenerator(db, model.Project{}) + require.NoError(t, err) + projectReleaseDBColumns, err := modeltest.ExtractDBColumns(db, model.ProjectRelease{}) + require.NoError(t, err) + generateProjectReleaseDBRows, err := modeltest.NewDBRowsGenerator(db, model.ProjectRelease{}) + require.NoError(t, err) + t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - paramsOwner := "fake-name" - paramsIsPublic := model.Personal - params := &ListProjectsParams{ - Owner: ¶msOwner, - IsPublic: ¶msIsPublic, - Pagination: model.Pagination{Index: 1, Size: 10}, - } - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM project WHERE owner = \? AND is_public = \? AND status != \?`). - WithArgs(params.Owner, model.Personal, model.StatusDeleted). - WillReturnRows(mock.NewRows([]string{"COUNT(*)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND is_public = \? AND status != \? ORDER BY id ASC LIMIT \?, \?`). - WithArgs(params.Owner, model.Personal, model.StatusDeleted, 0, 10). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - projects, err := ctrl.ListProjects(ctx, params) - require.NoError(t, err) - require.NotNil(t, projects) - assert.Len(t, projects.Data, 1) - assert.Equal(t, "1", projects.Data[0].ID) - }) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + params := &CreateProjectParams{ + Name: "testproject", + Files: model.FileCollection{"main.go": "http://example.com/main.go"}, + Visibility: "public", + Description: "Test project description", + } - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + dbMock.ExpectBegin() + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Create(&model.Project{}). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnResult(sqlmock.NewResult(1, 1)) - ctx := context.Background() - paramsOwner := "fake-name" - paramsIsPublic := model.Personal - params := &ListProjectsParams{ - Owner: ¶msOwner, - IsPublic: ¶msIsPublic, - Pagination: model.Pagination{Index: 1, Size: 10}, - } - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM project WHERE owner = \? AND is_public = \? AND status != \?`). - WithArgs(params.Owner, model.Public, model.StatusDeleted). - WillReturnRows(mock.NewRows([]string{"COUNT(*)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND is_public = \? AND status != \? ORDER BY id ASC LIMIT \?, \?`). - WithArgs(params.Owner, model.Public, model.StatusDeleted, 0, 10). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - projects, err := ctrl.ListProjects(ctx, params) + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.User{}). + Updates(map[string]any{ + "project_count": gorm.Expr("project_count + 1"), + "public_project_count": gorm.Expr("public_project_count + 1"), + }). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnResult(sqlmock.NewResult(0, 1)) + dbMock.ExpectCommit() + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + First(&model.Project{Model: model.Model{ID: 1}}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(model.Project{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID, + Name: params.Name, + Files: params.Files, + Visibility: model.ParseVisibility(params.Visibility), + Description: params.Description, + })...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + projectDTO, err := ctrl.CreateProject(ctx, params) require.NoError(t, err) - require.NotNil(t, projects) - assert.Len(t, projects.Data, 1) - assert.Equal(t, "1", projects.Data[0].ID) + assert.Equal(t, params.Name, projectDTO.Name) + assert.Equal(t, params.Visibility, projectDTO.Visibility) + assert.Equal(t, params.Description, projectDTO.Description) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("NoOwner", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + t.Run("CreateRemix", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - paramsIsPublic := model.Personal - params := &ListProjectsParams{ - IsPublic: ¶msIsPublic, - Pagination: model.Pagination{Index: 1, Size: 10}, - } - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM project WHERE is_public = \? AND status != \?`). - WithArgs(model.Public, model.StatusDeleted). - WillReturnRows(mock.NewRows([]string{"COUNT(*)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM project WHERE is_public = \? AND status != \? ORDER BY id ASC LIMIT \?, \?`). - WithArgs(model.Public, model.StatusDeleted, 0, 10). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - projects, err := ctrl.ListProjects(ctx, params) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mSourceProjectOwnerUsername := "otheruser" + + mSourceProject := model.Project{ + Model: model.Model{ID: 2}, + OwnerID: mUser.ID + 1, + Name: "sourceproject", + Visibility: model.VisibilityPublic, + } + + mSourceProjectRelease := model.ProjectRelease{ + Model: model.Model{ID: 1}, + ProjectID: mSourceProject.ID, + Name: "v1.0.0", + } + + params := &CreateProjectParams{ + RemixSource: fmt.Sprintf("%s/%s", mSourceProjectOwnerUsername, mSourceProject.Name), + Name: "remixproject", + Files: model.FileCollection{"main.go": "http://example.com/main.go"}, + Visibility: "public", + Description: "Remixed project description", + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mSourceProjectOwnerUsername). + Where("project.name = ?", mSourceProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mSourceProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mSourceProject.OwnerID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(model.User{ + Model: model.Model{ID: mSourceProject.OwnerID}, + Username: mSourceProjectOwnerUsername, + })...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("project_id = ?", mSourceProject.ID). + Order("created_at DESC"). + First(&model.ProjectRelease{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectReleaseDBColumns).AddRows(generateProjectReleaseDBRows(mSourceProjectRelease)...)) + + dbMock.ExpectBegin() + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Create(&model.Project{}). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnResult(sqlmock.NewResult(3, 1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.User{}). + Updates(map[string]any{ + "project_count": gorm.Expr("project_count + 1"), + "public_project_count": gorm.Expr("public_project_count + 1"), + }). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnResult(sqlmock.NewResult(0, 1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.ProjectRelease{}). + Where("id = ?", mSourceProjectRelease.ID). + Update("remix_count", gorm.Expr("remix_count + 1")). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnResult(sqlmock.NewResult(0, 1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.Project{}). + Joins("JOIN project_release ON project_release.project_id = project.id"). + Where("project_release.id = ?", mSourceProjectRelease.ID). + Update("project.remix_count", gorm.Expr("project.remix_count + 1")). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnResult(sqlmock.NewResult(0, 1)) + dbMock.ExpectCommit() + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + First(&model.Project{Model: model.Model{ID: 3}}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(model.Project{ + Model: model.Model{ID: 3}, + OwnerID: mUser.ID, + Name: params.Name, + Files: params.Files, + Visibility: model.ParseVisibility(params.Visibility), + Description: params.Description, + RemixedFromReleaseID: sql.NullInt64{Int64: mSourceProjectRelease.ID, Valid: true}, + })...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`project_release`.`id` = ?", mSourceProjectRelease.ID). + Find(&model.ProjectRelease{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectReleaseDBColumns).AddRows(generateProjectReleaseDBRows(mSourceProjectRelease)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`project`.`id` = ?", mSourceProject.ID). + Find(&model.Project{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mSourceProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mSourceProject.OwnerID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(model.User{ + Model: model.Model{ID: mSourceProject.OwnerID}, + Username: mSourceProjectOwnerUsername, + })...)) + + projectDTO, err := ctrl.CreateProject(ctx, params) require.NoError(t, err) - require.NotNil(t, projects) - assert.Len(t, projects.Data, 1) - assert.Equal(t, "1", projects.Data[0].ID) + assert.Equal(t, params.Name, projectDTO.Name) + assert.Equal(t, params.Visibility, projectDTO.Visibility) + assert.Equal(t, params.Description, projectDTO.Description) + assert.Equal(t, fmt.Sprintf("%s/%s/%s", mSourceProjectOwnerUsername, mSourceProject.Name, mSourceProjectRelease.Name), projectDTO.RemixedFrom) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("DifferentOwner", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + t.Run("Unauthorized", func(t *testing.T) { + ctrl, _, closeDB := newTestController(t) + defer closeDB() - ctx := newContextWithTestUser(context.Background()) - paramsOwner := "another-fake-name" - paramsIsPublic := model.Personal - params := &ListProjectsParams{ - Owner: ¶msOwner, - IsPublic: ¶msIsPublic, - Pagination: model.Pagination{Index: 1, Size: 10}, - } - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM project WHERE owner = \? AND is_public = \? AND status != \?`). - WithArgs(params.Owner, model.Public, model.StatusDeleted). - WillReturnRows(mock.NewRows([]string{"COUNT(*)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND is_public = \? AND status != \? ORDER BY id ASC LIMIT \?, \?`). - WithArgs(params.Owner, model.Public, model.StatusDeleted, 0, 10). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "another-fake-name")) - projects, err := ctrl.ListProjects(ctx, params) - require.NoError(t, err) - require.NotNil(t, projects) - assert.Len(t, projects.Data, 1) - assert.Equal(t, "1", projects.Data[0].ID) + params := &CreateProjectParams{ + Name: "testproject", + Visibility: "public", + } + + _, err := ctrl.CreateProject(context.Background(), params) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnauthorized) }) - t.Run("ClosedDB", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - ctrl.db.Close() + t.Run("CreateFailed", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - paramsOwner := "fake-name" - paramsIsPublic := model.Personal - params := &ListProjectsParams{ - Owner: ¶msOwner, - IsPublic: ¶msIsPublic, - Pagination: model.Pagination{Index: 1, Size: 10}, + + params := &CreateProjectParams{ + Name: "testproject", + Visibility: "public", } - _, err = ctrl.ListProjects(ctx, params) + + dbMock.ExpectBegin() + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Create(&model.Project{}). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnError(errors.New("create project failed")) + dbMock.ExpectRollback() + + _, err := ctrl.CreateProject(ctx, params) require.Error(t, err) - assert.EqualError(t, err, "sql: database is closed") + assert.EqualError(t, err, fmt.Sprintf("failed to create project: %s", "create project failed")) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) } -func TestAddProjectParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - params := &AddProjectParams{ - Name: "fake-name", - Owner: "fake-owner", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } +func TestListProjectsOrderBy(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + assert.True(t, ListProjectsOrderByCreatedAt.IsValid()) + assert.True(t, ListProjectsOrderByUpdatedAt.IsValid()) + assert.True(t, ListProjectsOrderByLikeCount.IsValid()) + assert.True(t, ListProjectsOrderByRemixCount.IsValid()) + assert.True(t, ListProjectsOrderByRecentLikeCount.IsValid()) + assert.True(t, ListProjectsOrderByRecentRemixCount.IsValid()) + }) + + t.Run("Invalid", func(t *testing.T) { + assert.False(t, ListProjectsOrderBy("invalid").IsValid()) + }) +} + +func TestListProjectsParams(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + params := NewListProjectsParams() ok, msg := params.Validate() assert.True(t, ok) assert.Empty(t, msg) }) - t.Run("EmptyName", func(t *testing.T) { - params := &AddProjectParams{ - Name: "", - Owner: "fake-owner", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } + t.Run("ValidWithAllFields", func(t *testing.T) { + params := NewListProjectsParams() + params.Owner = stringPtr("testuser") + params.RemixedFrom = stringPtr("user/project") + params.Keyword = stringPtr("test") + params.Visibility = stringPtr("public") + params.Liker = stringPtr("liker") + params.CreatedAfter = &time.Time{} + params.LikesReceivedAfter = &time.Time{} + params.RemixesReceivedAfter = &time.Time{} + params.FromFollowees = boolPtr(true) + params.OrderBy = ListProjectsOrderByLikeCount + params.SortOrder = SortOrderAsc + params.Pagination = Pagination{Index: 2, Size: 10} + + ok, msg := params.Validate() + assert.True(t, ok) + assert.Empty(t, msg) + }) + + t.Run("InvalidRemixedFrom", func(t *testing.T) { + params := NewListProjectsParams() + params.RemixedFrom = stringPtr("invalid/project/name") ok, msg := params.Validate() assert.False(t, ok) - assert.Equal(t, "missing name", msg) + assert.Equal(t, "invalid remixedFrom", msg) }) - t.Run("InvalidName", func(t *testing.T) { - params := &AddProjectParams{ - Name: "fake-name@", - Owner: "fake-owner", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } + t.Run("InvalidVisibility", func(t *testing.T) { + params := NewListProjectsParams() + params.Visibility = stringPtr("invalid") ok, msg := params.Validate() assert.False(t, ok) - assert.Equal(t, "invalid name", msg) + assert.Equal(t, "invalid visibility", msg) }) - t.Run("EmptyOwner", func(t *testing.T) { - params := &AddProjectParams{ - Name: "fake-name", - Owner: "", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } + t.Run("InvalidOrderBy", func(t *testing.T) { + params := NewListProjectsParams() + params.OrderBy = ListProjectsOrderBy("invalid") ok, msg := params.Validate() assert.False(t, ok) - assert.Equal(t, "missing owner", msg) + assert.Equal(t, "invalid orderBy", msg) }) - t.Run("InvalidIsPublic", func(t *testing.T) { - params := &AddProjectParams{ - Name: "fake-name", - Owner: "fake-owner", - Files: model.FileCollection{}, - IsPublic: -1, - } + t.Run("InvalidSortOrder", func(t *testing.T) { + params := NewListProjectsParams() + params.SortOrder = SortOrder("invalid") + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "invalid sortOrder", msg) + }) + + t.Run("InvalidPagination", func(t *testing.T) { + params := NewListProjectsParams() + params.Pagination.Index = 0 ok, msg := params.Validate() assert.False(t, ok) - assert.Equal(t, "invalid isPublic", msg) + assert.Equal(t, "invalid pagination", msg) }) } -func TestControllerAddProject(t *testing.T) { +func TestControllerListProjects(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + projectDBColumns, err := modeltest.ExtractDBColumns(db, model.Project{}) + require.NoError(t, err) + generateProjectDBRows, err := modeltest.NewDBRowsGenerator(db, model.Project{}) + require.NoError(t, err) + projectReleaseDBColumns, err := modeltest.ExtractDBColumns(db, model.ProjectRelease{}) + require.NoError(t, err) + generateProjectReleaseDBRows, err := modeltest.NewDBRowsGenerator(db, model.ProjectRelease{}) + require.NoError(t, err) + t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjects := []model.Project{ + { + Model: model.Model{ID: 1}, + OwnerID: mUser.ID, + Name: "project1", + Visibility: model.VisibilityPublic, + Description: "Description 1", + }, + { + Model: model.Model{ID: 2}, + OwnerID: mUser.ID, + Name: "project2", + Visibility: model.VisibilityPublic, + Description: "Description 2", + }, + } + + params := NewListProjectsParams() + params.Pagination.Size = 2 + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Model(&model.Project{}). + Where("project.visibility = ?", model.VisibilityPublic). + Count(new(int64)). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("project.visibility = ?", model.VisibilityPublic). + Order("project.created_at desc"). + Limit(2). + Find(&[]model.Project{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows( + generateProjectDBRows(mProjects...)..., + )) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + result, err := ctrl.ListProjects(ctx, params) require.NoError(t, err) + assert.Equal(t, int64(2), result.Total) + assert.Len(t, result.Data, 2) + assert.Equal(t, mProjects[0].Name, result.Data[0].Name) + assert.Equal(t, mProjects[1].Name, result.Data[1].Name) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("WithOwner", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - params := &AddProjectParams{ - Name: "fake-project", - Owner: "fake-name", - Files: model.FileCollection{}, - IsPublic: model.Personal, + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjects := []model.Project{ + { + Model: model.Model{ID: 1}, + OwnerID: mUser.ID, + Name: "project1", + Visibility: model.VisibilityPublic, + Description: "Description 1", + }, } - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - mock.ExpectExec(`INSERT INTO project \(.+\) VALUES \(\?,\?,\?,\?,\?,\?,\?,\?\)`). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT \* FROM project WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - project, err := ctrl.AddProject(ctx, params) + + params := NewListProjectsParams() + params.Owner = &mUser.Username + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Model(&model.Project{}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", *params.Owner). + Count(new(int64)). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", *params.Owner). + Order("project.created_at desc"). + Limit(params.Pagination.Size). + Find(&[]model.Project{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows( + generateProjectDBRows(mProjects...)..., + )) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + result, err := ctrl.ListProjects(ctx, params) require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "1", project.ID) + assert.Equal(t, int64(1), result.Total) + assert.Len(t, result.Data, 1) + assert.Equal(t, mProjects[0].Name, result.Data[0].Name) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("Exist", func(t *testing.T) { - ctrl, mock, err := newTestController(t) + t.Run("WithKeyword", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjects := []model.Project{ + { + Model: model.Model{ID: 1}, + OwnerID: mUser.ID, + Name: "test_project", + Visibility: model.VisibilityPublic, + Description: "Test Description", + }, + } + + params := NewListProjectsParams() + keyword := "test" + params.Keyword = &keyword + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Model(&model.Project{}). + Where("project.name LIKE ?", "%"+*params.Keyword+"%"). + Where("project.visibility = ?", model.VisibilityPublic). + Count(new(int64)). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("project.name LIKE ?", "%"+*params.Keyword+"%"). + Where("project.visibility = ?", model.VisibilityPublic). + Order("project.created_at desc"). + Limit(params.Pagination.Size). + Find(&[]model.Project{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows( + generateProjectDBRows(mProjects...)..., + )) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + result, err := ctrl.ListProjects(ctx, params) require.NoError(t, err) + assert.Equal(t, int64(1), result.Total) + assert.Len(t, result.Data, 1) + assert.Equal(t, mProjects[0].Name, result.Data[0].Name) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("WithOrderBy", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - params := &AddProjectParams{ - Name: "fake-project", - Owner: "fake-name", - Files: model.FileCollection{}, - IsPublic: model.Personal, + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjects := []model.Project{ + { + Model: model.Model{ID: 1}, + OwnerID: mUser.ID, + Name: "popular_project", + Visibility: model.VisibilityPublic, + Description: "Popular Project", + LikeCount: 100, + }, + { + Model: model.Model{ID: 2}, + OwnerID: mUser.ID, + Name: "less_popular_project", + Visibility: model.VisibilityPublic, + Description: "Less Popular Project", + LikeCount: 50, + }, } - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - _, err = ctrl.AddProject(ctx, params) + + params := NewListProjectsParams() + params.OrderBy = ListProjectsOrderByLikeCount + params.SortOrder = SortOrderDesc + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Model(&model.Project{}). + Where("project.visibility = ?", model.VisibilityPublic). + Count(new(int64)). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("project.visibility = ?", model.VisibilityPublic). + Order("project.like_count desc"). + Limit(params.Pagination.Size). + Find(&[]model.Project{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows( + generateProjectDBRows(mProjects...)..., + )) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + result, err := ctrl.ListProjects(ctx, params) + require.NoError(t, err) + assert.Equal(t, int64(2), result.Total) + assert.Len(t, result.Data, 2) + assert.Equal(t, mProjects[0].Name, result.Data[0].Name) + assert.Equal(t, mProjects[1].Name, result.Data[1].Name) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("ErrorInCount", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + + params := NewListProjectsParams() + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Model(&model.Project{}). + Where("project.visibility = ?", model.VisibilityPublic). + Count(new(int64)). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnError(errors.New("count error")) + + _, err := ctrl.ListProjects(ctx, params) require.Error(t, err) - assert.ErrorIs(t, err, model.ErrExist) + assert.EqualError(t, err, "failed to count projects: count error") + + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("NoUser", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) + t.Run("ErrorInQuery", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() - ctx := context.Background() - params := &AddProjectParams{ - Name: "fake-project", - Owner: "fake-name", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - _, err = ctrl.AddProject(ctx, params) + ctx := newContextWithTestUser(context.Background()) + + params := NewListProjectsParams() + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Model(&model.Project{}). + Where("project.visibility = ?", model.VisibilityPublic). + Count(new(int64)). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("project.visibility = ?", model.VisibilityPublic). + Order("project.created_at desc"). + Limit(params.Pagination.Size). + Find(&[]model.Project{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnError(errors.New("query error")) + + _, err := ctrl.ListProjects(ctx, params) require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) + assert.EqualError(t, err, "failed to list projects: query error") + + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("UnexpectedUser", func(t *testing.T) { - ctrl, _, err := newTestController(t) + t.Run("WithRemixedFrom", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjects := []model.Project{ + { + Model: model.Model{ID: 1}, + OwnerID: mUser.ID, + Name: "remixed_project", + Visibility: model.VisibilityPublic, + Description: "Remixed Project", + RemixedFromReleaseID: sql.NullInt64{Int64: 1, Valid: true}, + }, + } + + params := NewListProjectsParams() + remixedFrom := "original_user/original_project" + params.RemixedFrom = &remixedFrom + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Model(&model.Project{}). + Joins("JOIN project_release ON project_release.id = project.remixed_from_release_id"). + Joins("JOIN project AS remixed_from_project ON remixed_from_project.id = project_release.project_id"). + Joins("JOIN user AS remixed_from_user ON remixed_from_user.id = remixed_from_project.owner_id"). + Where("remixed_from_user.username = ?", "original_user"). + Where("remixed_from_project.name = ?", "original_project"). + Where("project.visibility = ?", model.VisibilityPublic). + Count(new(int64)). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN project_release ON project_release.id = project.remixed_from_release_id"). + Joins("JOIN project AS remixed_from_project ON remixed_from_project.id = project_release.project_id"). + Joins("JOIN user AS remixed_from_user ON remixed_from_user.id = remixed_from_project.owner_id"). + Where("remixed_from_user.username = ?", "original_user"). + Where("remixed_from_project.name = ?", "original_project"). + Where("project.visibility = ?", model.VisibilityPublic). + Order("project.created_at desc"). + Limit(params.Pagination.Size). + Find(&[]model.Project{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows( + generateProjectDBRows(mProjects...)..., + )) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`project_release`.`id` = ?", 1). + Find(&model.ProjectRelease{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectReleaseDBColumns).AddRows(generateProjectReleaseDBRows(model.ProjectRelease{ + Model: model.Model{ID: 1}, + })...)) + + result, err := ctrl.ListProjects(ctx, params) require.NoError(t, err) + assert.Equal(t, int64(1), result.Total) + assert.Len(t, result.Data, 1) + assert.Equal(t, mProjects[0].Name, result.Data[0].Name) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) +} + +func TestControllerGetProject(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + projectDBColumns, err := modeltest.ExtractDBColumns(db, model.Project{}) + require.NoError(t, err) + generateProjectDBRows, err := modeltest.NewDBRowsGenerator(db, model.Project{}) + require.NoError(t, err) + + t.Run("Normal", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - params := &AddProjectParams{ - Name: "fake-project", - Owner: "another-fake-name", - Files: model.FileCollection{}, - IsPublic: model.Personal, + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID, + Name: "testproject", + Visibility: model.VisibilityPublic, + Description: "Test project description", } - _, err = ctrl.AddProject(ctx, params) + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mUser.Username). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + projectDTO, err := ctrl.GetProject(ctx, mUser.Username, mProject.Name) + require.NoError(t, err) + assert.Equal(t, mProject.Name, projectDTO.Name) + assert.Equal(t, mProject.Visibility.String(), projectDTO.Visibility) + assert.Equal(t, mProject.Description, projectDTO.Description) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("ProjectNotFound", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjectName := "nonexistent" + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mUser.Username). + Where("project.name = ?", mProjectName). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnError(gorm.ErrRecordNotFound) + + _, err := ctrl.GetProject(ctx, mUser.Username, mProjectName) require.Error(t, err) - assert.ErrorIs(t, err, ErrForbidden) + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("ClosedDB", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - ctrl.db.Close() + t.Run("Unauthorized", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - params := &AddProjectParams{ - Name: "fake-project", - Owner: "fake-name", - Files: model.FileCollection{}, - IsPublic: model.Personal, + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjectOwnerUsername := "otheruser" + + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID + 1, + Name: "privateproject", + Visibility: model.VisibilityPrivate, } - _, err = ctrl.AddProject(ctx, params) + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mProjectOwnerUsername). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mProject.OwnerID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(model.User{ + Model: model.Model{ID: mProject.OwnerID}, + Username: mProjectOwnerUsername, + })...)) + + _, err := ctrl.GetProject(ctx, mProjectOwnerUsername, mProject.Name) require.Error(t, err) - assert.EqualError(t, err, "sql: database is closed") + assert.ErrorIs(t, err, ErrForbidden) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) } -func TestUpdateProjectParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { +func TestUpdateProjectParams(t *testing.T) { + t.Run("Valid", func(t *testing.T) { params := &UpdateProjectParams{ - Files: model.FileCollection{}, - IsPublic: model.Personal, + Files: model.FileCollection{"main.go": "http://example.com/main.go"}, + Visibility: "public", + Description: "Updated project description", + Instructions: "Updated instructions", + Thumbnail: "http://example.com/updated-thumbnail.jpg", } ok, msg := params.Validate() assert.True(t, ok) assert.Empty(t, msg) }) - t.Run("InvalidIsPublic", func(t *testing.T) { + t.Run("InvalidVisibility", func(t *testing.T) { params := &UpdateProjectParams{ - Files: model.FileCollection{}, - IsPublic: -1, + Visibility: "invalid", } ok, msg := params.Validate() assert.False(t, ok) - assert.Equal(t, "invalid isPublic", msg) + assert.Equal(t, "invalid visibility", msg) }) } func TestControllerUpdateProject(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + projectDBColumns, err := modeltest.ExtractDBColumns(db, model.Project{}) + require.NoError(t, err) + generateProjectDBRows, err := modeltest.NewDBRowsGenerator(db, model.Project{}) + require.NoError(t, err) + t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID, + Name: "testproject", + Visibility: model.VisibilityPrivate, + Description: "Original description", + } + params := &UpdateProjectParams{ - Files: model.FileCollection{}, - IsPublic: model.Public, - } - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner", "files", "is_public"}). - AddRow(1, "fake-project", "fake-name", []byte("{}"), model.Personal)) - mock.ExpectExec(`UPDATE project SET u_time=\?,version=\?,files=\?,is_public=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), model.Public, "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT \* FROM project WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner", "files", "is_public"}). - AddRow(1, "fake-project", "fake-name", []byte("{}"), model.Public)) - project, err := ctrl.UpdateProject(ctx, "fake-name", "fake-project", params) + Visibility: "public", + Description: "Updated description", + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mUser.Username). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + dbMock.ExpectBegin() + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.Project{}). + Updates(map[string]any{ + "visibility": model.ParseVisibility(params.Visibility), + "description": params.Description, + }). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnResult(sqlmock.NewResult(0, 1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.User{}). + Update("public_project_count", gorm.Expr("public_project_count + 1")). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnResult(sqlmock.NewResult(0, 1)) + dbMock.ExpectCommit() + + updatedProject, err := ctrl.UpdateProject(ctx, mUser.Username, mProject.Name, params) require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "1", project.ID) - assert.Equal(t, model.Public, project.IsPublic) + assert.Equal(t, params.Visibility, updatedProject.Visibility) + assert.Equal(t, params.Description, updatedProject.Description) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + t.Run("ProjectNotFound", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjectName := "nonexistent" - ctx := context.Background() params := &UpdateProjectParams{ - Files: model.FileCollection{}, - IsPublic: model.Public, + Visibility: "public", } - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner", "files", "is_public"}). - AddRow(1, "fake-project", "fake-name", []byte("{}"), model.Personal)) - _, err = ctrl.UpdateProject(ctx, "fake-name", "fake-project", params) + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mUser.Username). + Where("project.name = ?", mProjectName). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnError(gorm.ErrRecordNotFound) + + _, err := ctrl.UpdateProject(ctx, mUser.Username, mProjectName, params) require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("UnexpectedUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + t.Run("Unauthorized", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjectOwnerUsername := "otheruser" + + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID + 1, + Name: "otherproject", + Visibility: model.VisibilityPublic, + } + params := &UpdateProjectParams{ - Files: model.FileCollection{}, - IsPublic: model.Public, + Visibility: "private", } - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner", "files", "is_public"}). - AddRow(1, "fake-project", "another-fake-name", []byte("{}"), model.Personal)) - _, err = ctrl.UpdateProject(ctx, "fake-name", "fake-project", params) + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mProjectOwnerUsername). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mProject.OwnerID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(model.User{ + Model: model.Model{ID: mProject.OwnerID}, + Username: mProjectOwnerUsername, + })...)) + + _, err := ctrl.UpdateProject(ctx, mProjectOwnerUsername, mProject.Name, params) require.Error(t, err) assert.ErrorIs(t, err, ErrForbidden) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("NoProject", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + t.Run("NoChanges", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID, + Name: "testproject", + Visibility: model.VisibilityPublic, + Description: "Original description", + } + params := &UpdateProjectParams{ - Files: model.FileCollection{}, - IsPublic: model.Public, + Visibility: "public", + Description: "Original description", } - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - _, err = ctrl.UpdateProject(ctx, "fake-name", "fake-project", params) - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mUser.Username). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + updatedProject, err := ctrl.UpdateProject(ctx, mUser.Username, mProject.Name, params) + require.NoError(t, err) + assert.Equal(t, mProject.Visibility.String(), updatedProject.Visibility) + assert.Equal(t, mProject.Description, updatedProject.Description) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) +} + +func TestControllerDeleteProject(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + projectDBColumns, err := modeltest.ExtractDBColumns(db, model.Project{}) + require.NoError(t, err) + generateProjectDBRows, err := modeltest.NewDBRowsGenerator(db, model.Project{}) + require.NoError(t, err) + + t.Run("Normal", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - ctrl, mock, err := newTestController(t) + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID, + Name: "testproject", + Visibility: model.VisibilityPublic, + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mUser.Username). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + dbMock.ExpectBegin() + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Delete(&model.Project{Model: mProject.Model}). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnResult(sqlmock.NewResult(0, 1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.User{}). + Updates(map[string]any{ + "project_count": gorm.Expr("project_count - 1"), + "public_project_count": gorm.Expr("public_project_count - 1"), + }). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnResult(sqlmock.NewResult(0, 1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.User{}). + Joins("JOIN user_project_relationship ON user_project_relationship.user_id = user.id"). + Where("user_project_relationship.project_id = ?", mProject.ID). + Where("user_project_relationship.liked_at IS NOT NULL"). + Update("user.liked_project_count", gorm.Expr("user.liked_project_count - 1")). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnResult(sqlmock.NewResult(0, 1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Where("project_id = ?", mProject.ID). + Delete(&model.UserProjectRelationship{}). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnResult(sqlmock.NewResult(0, 1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.Project{}). + Joins("JOIN project_release ON project_release.id = project.remixed_from_release_id"). + Where("project_release.project_id = ?", mProject.ID). + Update("project.remixed_from_release_id", sql.NullInt64{}). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnResult(sqlmock.NewResult(0, 1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Where("project_id = ?", mProject.ID). + Delete(&model.ProjectRelease{}). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnResult(sqlmock.NewResult(0, 1)) + + dbMock.ExpectCommit() + + err := ctrl.DeleteProject(ctx, mUser.Username, mProject.Name) require.NoError(t, err) + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("ProjectNotFound", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + ctx := newContextWithTestUser(context.Background()) - params := &UpdateProjectParams{ - Files: model.FileCollection{}, - IsPublic: model.Public, - } - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner", "files", "is_public"}). - AddRow(1, "fake-project", "fake-name", []byte("{}"), model.Personal)) - mock.ExpectExec(`UPDATE project SET u_time=\?,version=\?,files=\?,is_public=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), model.Public, "1"). - WillReturnError(sql.ErrConnDone) - _, err = ctrl.UpdateProject(ctx, "fake-name", "fake-project", params) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjectName := "nonexistent" + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mUser.Username). + Where("project.name = ?", mProjectName). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnError(gorm.ErrRecordNotFound) + + err := ctrl.DeleteProject(ctx, mUser.Username, mProjectName) + require.Error(t, err) + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("Unauthorized", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjectOwnerUsername := "otheruser" + + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID + 1, + Name: "testproject", + Visibility: model.VisibilityPublic, + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mProjectOwnerUsername). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mProject.OwnerID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(model.User{ + Model: model.Model{ID: mProject.OwnerID}, + Username: mProjectOwnerUsername, + })...)) + + err := ctrl.DeleteProject(ctx, mProjectOwnerUsername, mProject.Name) require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) + assert.ErrorIs(t, err, ErrForbidden) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("DeleteFailed", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: mUser.ID, + Name: "testproject", + Visibility: model.VisibilityPublic, + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mUser.Username). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("`user`.`id` = ?", mUser.ID). + Find(&model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(*mUser)...)) + + dbMock.ExpectBegin() + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Delete(&model.Project{Model: mProject.Model}). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnError(errors.New("delete failed")) + dbMock.ExpectRollback() + + err := ctrl.DeleteProject(ctx, mUser.Username, mProject.Name) + require.Error(t, err) + assert.EqualError(t, err, fmt.Sprintf("failed to delete project: %s", "delete failed")) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) } -func TestControllerDeleteProject(t *testing.T) { +func TestControllerLikeProject(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + projectDBColumns, err := modeltest.ExtractDBColumns(db, model.Project{}) + require.NoError(t, err) + generateProjectDBRows, err := modeltest.NewDBRowsGenerator(db, model.Project{}) + require.NoError(t, err) + userProjectRelationshipDBColumns, err := modeltest.ExtractDBColumns(db, model.UserProjectRelationship{}) + require.NoError(t, err) + generateUserProjectRelationshipDBRows, err := modeltest.NewDBRowsGenerator(db, model.UserProjectRelationship{}) + require.NoError(t, err) + t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - mock.ExpectExec(`UPDATE project SET u_time=\?,status=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), model.StatusDeleted, "1"). + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjectOwnerUsername := "otheruser" + + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: 2, + Name: "testproject", + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mProjectOwnerUsername). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("user_id = ?", mUser.ID). + Where("project_id = ?", mProject.ID). + First(&model.UserProjectRelationship{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnRows(sqlmock.NewRows(userProjectRelationshipDBColumns)) + + dbMock.ExpectBegin() + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Create(&model.UserProjectRelationship{}). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). WillReturnResult(sqlmock.NewResult(1, 1)) - err = ctrl.DeleteProject(ctx, "fake-name", "fake-project") + dbMock.ExpectCommit() + + dbMock.ExpectBegin() + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.UserProjectRelationship{Model: model.Model{ID: 1}}). + Update("liked_at", sqlmock.AnyArg()). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[1] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(sqlmock.NewResult(0, 1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.User{Model: mUser.Model}). + Update("liked_project_count", gorm.Expr("liked_project_count + 1")). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[0] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(sqlmock.NewResult(0, 1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.Project{Model: mProject.Model}). + Update("like_count", gorm.Expr("like_count + 1")). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[0] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(sqlmock.NewResult(0, 1)) + dbMock.ExpectCommit() + + err := ctrl.LikeProject(ctx, mProjectOwnerUsername, mProject.Name) require.NoError(t, err) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) + t.Run("AlreadyLiked", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjectOwnerUsername := "otheruser" + + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: 2, + Name: "testproject", + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mProjectOwnerUsername). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("user_id = ?", mUser.ID). + Where("project_id = ?", mProject.ID). + First(&model.UserProjectRelationship{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnRows(sqlmock.NewRows(userProjectRelationshipDBColumns).AddRows(generateUserProjectRelationshipDBRows(model.UserProjectRelationship{ + Model: model.Model{ID: 1}, + UserID: mUser.ID, + ProjectID: mProject.ID, + LikedAt: sql.NullTime{Valid: true, Time: time.Now()}, + })...)) + + err := ctrl.LikeProject(ctx, mProjectOwnerUsername, mProject.Name) require.NoError(t, err) - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - err = ctrl.DeleteProject(ctx, "fake-name", "fake-project") + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("Unauthorized", func(t *testing.T) { + ctrl, _, closeDB := newTestController(t) + defer closeDB() + + err := ctrl.LikeProject(context.Background(), "otheruser", "testproject") require.Error(t, err) assert.ErrorIs(t, err, ErrUnauthorized) }) - t.Run("UnexpectedUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) + t.Run("ProjectNotFound", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "another-fake-name")) - err = ctrl.DeleteProject(ctx, "fake-name", "fake-project") + + mProjectOwnerUsername := "otheruser" + mProjectName := "nonexistent" + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mProjectOwnerUsername). + Where("project.name = ?", mProjectName). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnError(gorm.ErrRecordNotFound) + + err := ctrl.LikeProject(ctx, mProjectOwnerUsername, mProjectName) require.Error(t, err) - assert.ErrorIs(t, err, ErrForbidden) + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) +} + +func TestControllerHasLikedProject(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + projectDBColumns, err := modeltest.ExtractDBColumns(db, model.Project{}) + require.NoError(t, err) + generateProjectDBRows, err := modeltest.NewDBRowsGenerator(db, model.Project{}) + require.NoError(t, err) + + t.Run("HasLiked", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjectOwnerUsername := "otheruser" + + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: 2, + Name: "testproject", + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mProjectOwnerUsername). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Select("id"). + Where("user_id = ?", mUser.ID). + Where("project_id = ?", mProject.ID). + Where("liked_at IS NOT NULL"). + First(&model.UserProjectRelationship{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + + hasLiked, err := ctrl.HasLikedProject(ctx, mProjectOwnerUsername, mProject.Name) + require.NoError(t, err) + assert.True(t, hasLiked) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("HasNotLiked", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjectOwnerUsername := "otheruser" + + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: 2, + Name: "testproject", + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mProjectOwnerUsername). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Select("id"). + Where("user_id = ?", mUser.ID). + Where("project_id = ?", mProject.ID). + Where("liked_at IS NOT NULL"). + First(&model.UserProjectRelationship{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnError(gorm.ErrRecordNotFound) + + hasLiked, err := ctrl.HasLikedProject(ctx, mProjectOwnerUsername, mProject.Name) + require.NoError(t, err) + assert.False(t, hasLiked) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("NoProject", func(t *testing.T) { - ctrl, mock, err := newTestController(t) + t.Run("Unauthorized", func(t *testing.T) { + ctrl, _, closeDB := newTestController(t) + defer closeDB() + + hasLiked, err := ctrl.HasLikedProject(context.Background(), "otheruser", "testproject") require.NoError(t, err) + assert.False(t, hasLiked) + }) + + t.Run("ProjectNotFound", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - err = ctrl.DeleteProject(ctx, "fake-name", "fake-project") + + mProjectOwnerUsername := "otheruser" + mProjectName := "nonexistent" + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mProjectOwnerUsername). + Where("project.name = ?", mProjectName). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnError(gorm.ErrRecordNotFound) + + _, err := ctrl.HasLikedProject(ctx, mProjectOwnerUsername, mProjectName) require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) +} + +func TestControllerUnlikeProject(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + projectDBColumns, err := modeltest.ExtractDBColumns(db, model.Project{}) + require.NoError(t, err) + generateProjectDBRows, err := modeltest.NewDBRowsGenerator(db, model.Project{}) + require.NoError(t, err) + userProjectRelationshipDBColumns, err := modeltest.ExtractDBColumns(db, model.UserProjectRelationship{}) + require.NoError(t, err) + generateUserProjectRelationshipDBRows, err := modeltest.NewDBRowsGenerator(db, model.UserProjectRelationship{}) + require.NoError(t, err) + + t.Run("Normal", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjectOwnerUsername := "otheruser" + + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: 2, + Name: "testproject", + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mProjectOwnerUsername). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("user_id = ?", mUser.ID). + Where("project_id = ?", mProject.ID). + First(&model.UserProjectRelationship{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnRows(sqlmock.NewRows(userProjectRelationshipDBColumns).AddRows(generateUserProjectRelationshipDBRows(model.UserProjectRelationship{ + Model: model.Model{ID: 1}, + UserID: mUser.ID, + ProjectID: mProject.ID, + LikedAt: sql.NullTime{Valid: true, Time: time.Now()}, + })...)) + + dbMock.ExpectBegin() + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.UserProjectRelationship{Model: model.Model{ID: 1}}). + Update("liked_at", sql.NullTime{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[1] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(sqlmock.NewResult(0, 1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.User{Model: mUser.Model}). + Update("liked_project_count", gorm.Expr("liked_project_count - 1")). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[0] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(sqlmock.NewResult(0, 1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.Project{Model: mProject.Model}). + Update("like_count", gorm.Expr("like_count - 1")). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[0] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(sqlmock.NewResult(0, 1)) + dbMock.ExpectCommit() + + err := ctrl.UnlikeProject(ctx, mProjectOwnerUsername, mProject.Name) + require.NoError(t, err) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - ctrl, mock, err := newTestController(t) + t.Run("NotLiked", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mProjectOwnerUsername := "otheruser" + + mProject := model.Project{ + Model: model.Model{ID: 1}, + OwnerID: 2, + Name: "testproject", + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mProjectOwnerUsername). + Where("project.name = ?", mProject.Name). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(projectDBColumns).AddRows(generateProjectDBRows(mProject)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("user_id = ?", mUser.ID). + Where("project_id = ?", mProject.ID). + First(&model.UserProjectRelationship{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnRows(sqlmock.NewRows(userProjectRelationshipDBColumns).AddRows(generateUserProjectRelationshipDBRows(model.UserProjectRelationship{ + Model: model.Model{ID: 1}, + UserID: mUser.ID, + ProjectID: mProject.ID, + LikedAt: sql.NullTime{Valid: false}, + })...)) + + err := ctrl.UnlikeProject(ctx, mProjectOwnerUsername, mProject.Name) require.NoError(t, err) + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("Unauthorized", func(t *testing.T) { + ctrl, _, closeDB := newTestController(t) + defer closeDB() + + err := ctrl.UnlikeProject(context.Background(), "otheruser", "testproject") + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnauthorized) + }) + + t.Run("ProjectNotFound", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - mock.ExpectExec(`UPDATE project SET u_time=\?,status=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), model.StatusDeleted, "1"). - WillReturnError(sql.ErrConnDone) - err = ctrl.DeleteProject(ctx, "fake-name", "fake-project") + + mProjectOwnerUsername := "otheruser" + mProjectName := "nonexistent" + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", mProjectOwnerUsername). + Where("project.name = ?", mProjectName). + First(&model.Project{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnError(gorm.ErrRecordNotFound) + + err := ctrl.UnlikeProject(ctx, mProjectOwnerUsername, mProjectName) require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) } diff --git a/spx-backend/internal/controller/user.go b/spx-backend/internal/controller/user.go index 512fd53f9..75ce36262 100644 --- a/spx-backend/internal/controller/user.go +++ b/spx-backend/internal/controller/user.go @@ -2,46 +2,363 @@ package controller import ( "context" + "database/sql" + "errors" "fmt" + "time" - "github.com/casdoor/casdoor-go-sdk/casdoorsdk" + "github.com/goplus/builder/spx-backend/internal/model" + "gorm.io/gorm" ) -// User is the user info. -type User casdoorsdk.User +// UserDTO is the DTO for users. +type UserDTO struct { + ModelDTO + + Username string `json:"username"` + Description string `json:"description"` + FollowerCount int64 `json:"followerCount"` + FollowingCount int64 `json:"followingCount"` + ProjectCount int64 `json:"projectCount"` + PublicProjectCount int64 `json:"publicProjectCount"` + LikedProjectCount int64 `json:"likedProjectCount"` +} + +// toUserDTO converts the model user to its DTO. +func toUserDTO(mUser model.User) UserDTO { + return UserDTO{ + ModelDTO: toModelDTO(mUser.Model), + Username: mUser.Username, + Description: mUser.Description, + FollowerCount: mUser.FollowerCount, + FollowingCount: mUser.FollowingCount, + ProjectCount: mUser.ProjectCount, + PublicProjectCount: mUser.PublicProjectCount, + LikedProjectCount: mUser.LikedProjectCount, + } +} // userContextKey is the context key for user. var userContextKey = &contextKey{"user"} // NewContextWithUser creates a new context with user. -func NewContextWithUser(ctx context.Context, user *User) context.Context { - return context.WithValue(ctx, userContextKey, user) +func NewContextWithUser(ctx context.Context, mUser *model.User) context.Context { + return context.WithValue(ctx, userContextKey, mUser) } // UserFromContext gets user from context. -func UserFromContext(ctx context.Context) (user *User, ok bool) { - user, ok = ctx.Value(userContextKey).(*User) +func UserFromContext(ctx context.Context) (mUser *model.User, ok bool) { + mUser, ok = ctx.Value(userContextKey).(*model.User) return } -// EnsureUser ensures there is a user in the context and it matches the expected user. -func EnsureUser(ctx context.Context, expectedUser string) (*User, error) { - user, ok := UserFromContext(ctx) +// ensureUser ensures there is a user in the context and it matches the expected +// user. +func ensureUser(ctx context.Context, expectedUserID int64) (*model.User, error) { + mUser, ok := UserFromContext(ctx) if !ok { return nil, ErrUnauthorized } - if user.Name != expectedUser { + if mUser.ID != expectedUserID { return nil, ErrForbidden } - return user, nil + return mUser, nil } // UserFromToken gets user from the provided JWT token. -func (ctrl *Controller) UserFromToken(token string) (*User, error) { +func (ctrl *Controller) UserFromToken(ctx context.Context, token string) (*model.User, error) { claims, err := ctrl.casdoorClient.ParseJwtToken(token) if err != nil { return nil, fmt.Errorf("ctrl.casdoorClient.ParseJwtToken failed: %w", err) } - user := User(claims.User) - return &user, nil + mUser, err := model.FirstOrCreateUser(ctx, ctrl.db, claims.Name) + if err != nil { + return nil, err + } + return mUser, nil +} + +// ListUsersOrderBy is the order by condition for listing users. +type ListUsersOrderBy string + +const ( + ListUsersOrderByCreatedAt ListUsersOrderBy = "createdAt" + ListUsersOrderByUpdatedAt ListUsersOrderBy = "updatedAt" + ListUsersOrderByFollowedAt ListUsersOrderBy = "followedAt" +) + +// IsValid reports whether the order by condition is valid. +func (ob ListUsersOrderBy) IsValid() bool { + switch ob { + case ListUsersOrderByCreatedAt, ListUsersOrderByUpdatedAt, ListUsersOrderByFollowedAt: + return true + } + return false +} + +// ListUsersParams holds parameters for listing users. +type ListUsersParams struct { + // Follower filters users who are being followed by the specified user. + // + // Applied only if non-nil. + Follower *string + + // Followee filters users who are following the specified user. + // + // Applied only if non-nil. + Followee *string + + // OrderBy indicates the field by which to order the results. + OrderBy ListUsersOrderBy + + // SortOrder indicates the order in which to sort the results. + SortOrder SortOrder + + // Pagination is the pagination information. + Pagination Pagination +} + +// NewListUsersParams creates a new ListUsersParams. +func NewListUsersParams() *ListUsersParams { + return &ListUsersParams{ + OrderBy: ListUsersOrderByCreatedAt, + SortOrder: SortOrderDesc, + Pagination: Pagination{Index: 1, Size: 20}, + } +} + +// Validate validates the parameters. +func (p *ListUsersParams) Validate() (ok bool, msg string) { + if !p.OrderBy.IsValid() { + return false, "invalid orderBy" + } + if !p.SortOrder.IsValid() { + return false, "invalid sortOrder" + } + if !p.Pagination.IsValid() { + return false, "invalid pagination" + } + return true, "" +} + +// ListUsers retrieves a paginated list of users with optional filtering and ordering. +func (ctrl *Controller) ListUsers(ctx context.Context, params *ListUsersParams) (*ByPage[UserDTO], error) { + query := ctrl.db.WithContext(ctx).Model(&model.User{}) + joinedTables := map[string]struct{}{} + if params.Follower != nil { + query = query.Joins("JOIN user_relationship ON user_relationship.target_user_id = user.id"). + Where("user_relationship.user_id = ?", *params.Follower) + joinedTables["user_relationship"] = struct{}{} + } + if params.Followee != nil { + query = query.Joins("JOIN user_relationship ON user_relationship.user_id = user.id"). + Where("user_relationship.target_user_id = ?", *params.Followee) + joinedTables["user_relationship"] = struct{}{} + } + switch params.OrderBy { + case ListUsersOrderByCreatedAt: + query = query.Order(fmt.Sprintf("user.created_at %s", params.SortOrder)) + case ListUsersOrderByUpdatedAt: + query = query.Order(fmt.Sprintf("user.updated_at %s", params.SortOrder)) + case ListUsersOrderByFollowedAt: + if _, ok := joinedTables["user_relationship"]; !ok { + query = query.Order(fmt.Sprintf("user.created_at %s", params.SortOrder)) + break + } + query = query.Order(fmt.Sprintf("user_relationship.followed_at %s", params.SortOrder)) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, fmt.Errorf("failed to count users: %w", err) + } + + var mUsers []model.User + if err := query. + Offset(params.Pagination.Offset()). + Limit(params.Pagination.Size). + Find(&mUsers). + Error; err != nil { + return nil, fmt.Errorf("failed to list users: %w", err) + } + userDTOs := make([]UserDTO, len(mUsers)) + for i, mUser := range mUsers { + userDTOs[i] = toUserDTO(mUser) + } + return &ByPage[UserDTO]{ + Total: total, + Data: userDTOs, + }, nil +} + +// GetUser gets user by username. +func (ctrl *Controller) GetUser(ctx context.Context, username string) (*UserDTO, error) { + var mUser model.User + if err := ctrl.db.WithContext(ctx). + Where("username = ?", username). + First(&mUser). + Error; err != nil { + return nil, fmt.Errorf("failed to get user %s: %w", username, err) + } + userDTO := toUserDTO(mUser) + return &userDTO, nil +} + +// UpdateAuthedUserParams holds parameters for updating the authenticated user. +type UpdateAuthedUserParams struct { + Description string +} + +// Validate validates the parameters. +func (p *UpdateAuthedUserParams) Validate() (ok bool, msg string) { + return true, "" +} + +// UpdateAuthedUser updates the authenticated user. +func (ctrl *Controller) UpdateAuthedUser(ctx context.Context, params *UpdateAuthedUserParams) (*UserDTO, error) { + mUser, ok := UserFromContext(ctx) + if !ok { + return nil, ErrUnauthorized + } + updates := map[string]any{} + if params.Description != mUser.Description { + updates["description"] = params.Description + } + if len(updates) > 0 { + if err := ctrl.db.WithContext(ctx).Model(mUser).Updates(updates).Error; err != nil { + return nil, fmt.Errorf("failed to update authenticated user %s: %w", mUser.Username, err) + } + } + userDTO := toUserDTO(*mUser) + return &userDTO, nil +} + +// FollowUser follows the target user as the authenticated user. +func (ctrl *Controller) FollowUser(ctx context.Context, targetUsername string) error { + mUser, ok := UserFromContext(ctx) + if !ok { + return ErrUnauthorized + } + if mUser.Username == targetUsername { + return ErrBadRequest + } + + var mTargetUser model.User + if err := ctrl.db.WithContext(ctx). + Where("username = ?", targetUsername). + First(&mTargetUser). + Error; err != nil { + return fmt.Errorf("failed to get target user %s: %w", targetUsername, err) + } + + mUserRelationship, err := model.FirstOrCreateUserRelationship(ctx, ctrl.db, mUser.ID, mTargetUser.ID) + if err != nil { + return err + } + if mUserRelationship.FollowedAt.Valid { + return nil + } + + if err := ctrl.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if queryResult := tx. + Model(mUserRelationship). + Update("followed_at", sql.NullTime{ + Time: time.Now().UTC(), + Valid: true, + }); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return nil + } + if err := tx.Model(mUser).Update("following_count", gorm.Expr("following_count + 1")).Error; err != nil { + return err + } + if err := tx.Model(&mTargetUser).Update("follower_count", gorm.Expr("follower_count + 1")).Error; err != nil { + return err + } + return nil + }); err != nil { + return fmt.Errorf("failed to follow user %s: %w", targetUsername, err) + } + return nil +} + +// IsFollowingUser checks if the authenticated user is following the target user. +func (ctrl *Controller) IsFollowingUser(ctx context.Context, targetUsername string) (bool, error) { + mUser, ok := UserFromContext(ctx) + if !ok { + return false, ErrUnauthorized + } + if mUser.Username == targetUsername { + return false, ErrBadRequest + } + + var mTargetUser model.User + if err := ctrl.db.WithContext(ctx). + Where("username = ?", targetUsername). + First(&mTargetUser). + Error; err != nil { + return false, fmt.Errorf("failed to get target user %s: %w", targetUsername, err) + } + + if err := ctrl.db.WithContext(ctx). + Select("id"). + Where("user_id = ?", mUser.ID). + Where("target_user_id = ?", mTargetUser.ID). + Where("followed_at IS NOT NULL"). + First(&model.UserRelationship{}). + Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, fmt.Errorf("failed to check if user %s is following user %s: %w", mUser.Username, targetUsername, err) + } + return true, nil +} + +// UnfollowUser unfollows the target user as the authenticated user. +func (ctrl *Controller) UnfollowUser(ctx context.Context, targetUsername string) error { + mUser, ok := UserFromContext(ctx) + if !ok { + return ErrUnauthorized + } + if mUser.Username == targetUsername { + return ErrBadRequest + } + + var mTargetUser model.User + if err := ctrl.db.WithContext(ctx). + Where("username = ?", targetUsername). + First(&mTargetUser). + Error; err != nil { + return fmt.Errorf("failed to get target user %s: %w", targetUsername, err) + } + + mUserRelationship, err := model.FirstOrCreateUserRelationship(ctx, ctrl.db, mUser.ID, mTargetUser.ID) + if err != nil { + return err + } + if !mUserRelationship.FollowedAt.Valid { + return nil + } + + if err := ctrl.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if queryResult := tx. + Model(mUserRelationship). + Update("followed_at", sql.NullTime{}); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return nil + } + if err := tx.Model(mUser).Update("following_count", gorm.Expr("following_count - 1")).Error; err != nil { + return err + } + if err := tx.Model(&mTargetUser).Update("follower_count", gorm.Expr("follower_count - 1")).Error; err != nil { + return err + } + return nil + }); err != nil { + return fmt.Errorf("failed to unfollow user %s: %w", targetUsername, err) + } + return nil } diff --git a/spx-backend/internal/controller/user_test.go b/spx-backend/internal/controller/user_test.go index d552ec093..43798a14a 100644 --- a/spx-backend/internal/controller/user_test.go +++ b/spx-backend/internal/controller/user_test.go @@ -2,17 +2,30 @@ package controller import ( "context" + "database/sql" + "errors" + "fmt" + "regexp" "testing" + "github.com/DATA-DOG/go-sqlmock" + "github.com/goplus/builder/spx-backend/internal/model" + "github.com/goplus/builder/spx-backend/internal/model/modeltest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) -func newTestUser() *User { - return &User{ - Id: "fake-id", - Name: "fake-name", - Owner: "fake-owner", +func newTestUser() *model.User { + return &model.User{ + Model: model.Model{ID: 1}, + Username: "fake-name", + Description: "fake-description", + FollowerCount: 10, + FollowingCount: 5, + ProjectCount: 3, + PublicProjectCount: 2, + LikedProjectCount: 15, } } @@ -22,48 +35,52 @@ func newContextWithTestUser(ctx context.Context) context.Context { func TestNewContextWithUser(t *testing.T) { t.Run("Normal", func(t *testing.T) { - ctx := NewContextWithUser(context.Background(), newTestUser()) - user, ok := ctx.Value(userContextKey).(*User) + mExpectedUser := newTestUser() + ctx := NewContextWithUser(context.Background(), mExpectedUser) + mUser, ok := ctx.Value(userContextKey).(*model.User) require.True(t, ok) - require.NotNil(t, user) - assert.Equal(t, "fake-name", user.Name) + require.NotNil(t, mUser) + assert.Equal(t, *mExpectedUser, *mUser) }) } func TestUserFromContext(t *testing.T) { t.Run("Normal", func(t *testing.T) { + mExpectedUser := newTestUser() ctx := newContextWithTestUser(context.Background()) - user, ok := UserFromContext(ctx) + mUser, ok := UserFromContext(ctx) require.True(t, ok) - require.NotNil(t, user) - assert.Equal(t, "fake-name", user.Name) + require.NotNil(t, mUser) + assert.Equal(t, *mExpectedUser, *mUser) }) t.Run("NoUser", func(t *testing.T) { - user, ok := UserFromContext(context.Background()) + mUser, ok := UserFromContext(context.Background()) require.False(t, ok) - require.Nil(t, user) + require.Nil(t, mUser) }) } func TestEnsureUser(t *testing.T) { t.Run("Normal", func(t *testing.T) { - ctx := newContextWithTestUser(context.Background()) - user, err := EnsureUser(ctx, "fake-name") + mExpectedUser := newTestUser() + ctx := NewContextWithUser(context.Background(), mExpectedUser) + + mUser, err := ensureUser(ctx, 1) require.NoError(t, err) - require.NotNil(t, user) + assert.Equal(t, *mExpectedUser, *mUser) }) - t.Run("NoUser", func(t *testing.T) { - _, err := EnsureUser(context.Background(), "fake-name") - require.Error(t, err) + t.Run("ErrUnauthorized", func(t *testing.T) { + _, err := ensureUser(context.Background(), 1) assert.ErrorIs(t, err, ErrUnauthorized) }) - t.Run("UnexpectedUser", func(t *testing.T) { - ctx := newContextWithTestUser(context.Background()) - _, err := EnsureUser(ctx, "unexpected-name") - require.Error(t, err) + t.Run("ErrForbidden", func(t *testing.T) { + mExpectedUser := newTestUser() + ctx := NewContextWithUser(context.Background(), mExpectedUser) + + _, err := ensureUser(ctx, 65535) assert.ErrorIs(t, err, ErrForbidden) }) } @@ -74,21 +91,958 @@ const fakeUserToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + func TestControllerUserFromToken(t *testing.T) { t.Run("Normal", func(t *testing.T) { - ctrl, _, err := newTestController(t) + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + userDBColumns, err := modeltest.ExtractDBColumns(ctrl.db, model.User{}) require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(ctrl.db, model.User{}) + require.NoError(t, err) + + mExpectedUser := model.User{ + Model: model.Model{ID: 1}, + Username: "fake-name", + Description: "Test user description", + FollowerCount: 10, + FollowingCount: 5, + ProjectCount: 3, + PublicProjectCount: 2, + LikedProjectCount: 15, + } - user, err := ctrl.UserFromToken(fakeUserToken) + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("username = ?", mExpectedUser.Username). + First(&model.User{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(mExpectedUser)...)) + + mUser, err := ctrl.UserFromToken(context.Background(), fakeUserToken) require.NoError(t, err) - require.NotNil(t, user) - assert.Equal(t, "fake-name", user.Name) + require.NotNil(t, mUser) + assert.Equal(t, mExpectedUser, *mUser) + + require.NoError(t, dbMock.ExpectationsWereMet()) }) t.Run("InvalidToken", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) + ctrl, _, closeDB := newTestController(t) + defer closeDB() - _, err = ctrl.UserFromToken("invalid-token") + _, err := ctrl.UserFromToken(context.Background(), "invalid-token") require.Error(t, err) assert.EqualError(t, err, "ctrl.casdoorClient.ParseJwtToken failed: token contains an invalid number of segments") }) } + +func TestListUsersOrderBy(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + assert.True(t, ListUsersOrderByCreatedAt.IsValid()) + assert.True(t, ListUsersOrderByUpdatedAt.IsValid()) + assert.True(t, ListUsersOrderByFollowedAt.IsValid()) + }) + + t.Run("Invalid", func(t *testing.T) { + assert.False(t, ListUsersOrderBy("invalid").IsValid()) + }) +} + +func TestListUsersParams(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + params := NewListUsersParams() + ok, msg := params.Validate() + assert.True(t, ok) + assert.Empty(t, msg) + }) + + t.Run("InvalidOrderBy", func(t *testing.T) { + params := NewListUsersParams() + params.OrderBy = "" + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "invalid orderBy", msg) + }) + + t.Run("InvalidSortOrder", func(t *testing.T) { + params := NewListUsersParams() + params.SortOrder = "" + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "invalid sortOrder", msg) + }) + + t.Run("InvalidPagination", func(t *testing.T) { + params := NewListUsersParams() + params.Pagination.Index = 0 + ok, msg := params.Validate() + assert.False(t, ok) + assert.Equal(t, "invalid pagination", msg) + }) +} + +func TestControllerListUsers(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + + t.Run("Normal", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + mExpectedUsers := []model.User{ + { + Model: model.Model{ID: 1}, + Username: "user1", + Description: "desc1", + FollowerCount: 10, + FollowingCount: 5, + ProjectCount: 3, + PublicProjectCount: 2, + LikedProjectCount: 15, + }, + { + Model: model.Model{ID: 2}, + Username: "user2", + Description: "desc2", + FollowerCount: 20, + FollowingCount: 15, + ProjectCount: 5, + PublicProjectCount: 4, + LikedProjectCount: 25, + }, + } + + params := NewListUsersParams() + params.Pagination.Size = 2 + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Model(&model.User{}). + Count(new(int64)). + Statement + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Order("user.created_at desc"). + Limit(2). + Find(&[]model.User{}). + Statement + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(mExpectedUsers...)...)) + + result, err := ctrl.ListUsers(context.Background(), params) + require.NoError(t, err) + assert.Equal(t, int64(2), result.Total) + assert.Len(t, result.Data, 2) + assert.Equal(t, mExpectedUsers[0].Username, result.Data[0].Username) + assert.Equal(t, mExpectedUsers[1].Username, result.Data[1].Username) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("WithFollower", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + mExpectedUsers := []model.User{ + { + Model: model.Model{ID: 1}, + Username: "followee_user", + Description: "desc", + FollowerCount: 1, + }, + } + + params := NewListUsersParams() + params.Follower = stringPtr("follower_user") + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Model(&model.User{}). + Joins("JOIN user_relationship ON user_relationship.target_user_id = user.id"). + Where("user_relationship.user_id = ?", *params.Follower). + Count(new(int64)). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user_relationship ON user_relationship.target_user_id = user.id"). + Where("user_relationship.user_id = ?", *params.Follower). + Order("user.created_at desc"). + Limit(params.Pagination.Size). + Find(&[]model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(mExpectedUsers...)...)) + + result, err := ctrl.ListUsers(context.Background(), params) + require.NoError(t, err) + assert.Equal(t, int64(1), result.Total) + assert.Len(t, result.Data, 1) + assert.Equal(t, mExpectedUsers[0].Username, result.Data[0].Username) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("WithFollowee", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + mExpectedUsers := []model.User{ + { + Model: model.Model{ID: 1}, + Username: "follower_user", + Description: "desc", + FollowingCount: 1, + }, + } + + params := NewListUsersParams() + params.Followee = stringPtr("followee_user") + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Model(&model.User{}). + Joins("JOIN user_relationship ON user_relationship.user_id = user.id"). + Where("user_relationship.target_user_id = ?", *params.Followee). + Count(new(int64)). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user_relationship ON user_relationship.user_id = user.id"). + Where("user_relationship.target_user_id = ?", *params.Followee). + Order("user.created_at desc"). + Limit(params.Pagination.Size). + Find(&[]model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(mExpectedUsers...)...)) + + result, err := ctrl.ListUsers(context.Background(), params) + require.NoError(t, err) + assert.Equal(t, int64(1), result.Total) + assert.Len(t, result.Data, 1) + assert.Equal(t, mExpectedUsers[0].Username, result.Data[0].Username) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("OrderByFollowedAt", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + mExpectedUsers := []model.User{ + { + Model: model.Model{ID: 1}, + Username: "user1", + Description: "desc1", + }, + { + Model: model.Model{ID: 2}, + Username: "user2", + Description: "desc2", + }, + } + + params := NewListUsersParams() + params.OrderBy = ListUsersOrderByFollowedAt + params.Follower = stringPtr("follower_user") + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Model(&model.User{}). + Joins("JOIN user_relationship ON user_relationship.target_user_id = user.id"). + Where("user_relationship.user_id = ?", *params.Follower). + Count(new(int64)). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Joins("JOIN user_relationship ON user_relationship.target_user_id = user.id"). + Where("user_relationship.user_id = ?", *params.Follower). + Order("user_relationship.followed_at desc"). + Limit(params.Pagination.Size). + Find(&[]model.User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(mExpectedUsers...)...)) + + result, err := ctrl.ListUsers(context.Background(), params) + require.NoError(t, err) + assert.Equal(t, int64(2), result.Total) + assert.Len(t, result.Data, 2) + assert.Equal(t, mExpectedUsers[0].Username, result.Data[0].Username) + assert.Equal(t, mExpectedUsers[1].Username, result.Data[1].Username) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("ErrorInCount", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + params := NewListUsersParams() + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Model(&model.User{}). + Count(new(int64)). + Statement + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnError(errors.New("count error")) + + _, err := ctrl.ListUsers(context.Background(), params) + require.Error(t, err) + assert.EqualError(t, err, "failed to count users: count error") + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("ErrorInQuery", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + params := NewListUsersParams() + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Model(&model.User{}). + Count(new(int64)). + Statement + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Order("user.created_at desc"). + Find(&[]model.User{}). + Statement + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnError(errors.New("query error")) + + _, err := ctrl.ListUsers(context.Background(), params) + require.Error(t, err) + assert.EqualError(t, err, "failed to list users: query error") + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) +} + +func TestControllerGetUser(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + + t.Run("Normal", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + mExpectedUser := model.User{ + Model: model.Model{ID: 1}, + Username: "testuser", + Description: "Test user description", + FollowerCount: 10, + FollowingCount: 5, + ProjectCount: 3, + PublicProjectCount: 2, + LikedProjectCount: 15, + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("username = ?", mExpectedUser.Username). + First(&model.User{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(mExpectedUser)...)) + + result, err := ctrl.GetUser(context.Background(), mExpectedUser.Username) + require.NoError(t, err) + assert.Equal(t, mExpectedUser.Username, result.Username) + assert.Equal(t, mExpectedUser.Description, result.Description) + assert.Equal(t, mExpectedUser.FollowerCount, result.FollowerCount) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("UserNotFound", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + mUserUsername := "nonexistentuser" + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("username = ?", mUserUsername). + First(&model.User{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnError(gorm.ErrRecordNotFound) + + _, err := ctrl.GetUser(context.Background(), mUserUsername) + require.Error(t, err) + assert.EqualError(t, err, fmt.Sprintf("failed to get user %s: record not found", mUserUsername)) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) +} + +func TestUpdateAuthedUserParams(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + params := &UpdateAuthedUserParams{ + Description: "New description", + } + ok, msg := params.Validate() + assert.True(t, ok) + assert.Empty(t, msg) + }) +} + +func TestControllerUpdateAuthedUser(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + params := &UpdateAuthedUserParams{ + Description: "New description", + } + + dbMock.ExpectBegin() + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.User{Model: mUser.Model}). + Updates(map[string]any{"description": params.Description}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[1] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(sqlmock.NewResult(1, 1)) + dbMock.ExpectCommit() + + result, err := ctrl.UpdateAuthedUser(ctx, params) + require.NoError(t, err) + assert.Equal(t, params.Description, result.Description) + assert.Equal(t, mUser.Username, result.Username) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("NoChanges", func(t *testing.T) { + ctrl, _, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + params := &UpdateAuthedUserParams{ + Description: mUser.Description, + } + + result, err := ctrl.UpdateAuthedUser(ctx, params) + require.NoError(t, err) + assert.Equal(t, mUser.Description, result.Description) + assert.Equal(t, mUser.Username, result.Username) + }) + + t.Run("Unauthorized", func(t *testing.T) { + ctrl, _, closeDB := newTestController(t) + defer closeDB() + + ctx := context.Background() + + params := &UpdateAuthedUserParams{ + Description: "New description", + } + + _, err := ctrl.UpdateAuthedUser(ctx, params) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnauthorized) + }) + + t.Run("UpdateError", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + params := &UpdateAuthedUserParams{ + Description: "New description", + } + + dbMock.ExpectBegin() + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.User{Model: mUser.Model}). + Updates(map[string]any{"description": params.Description}). + Statement + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WillReturnError(errors.New("update error")) + dbMock.ExpectRollback() + + _, err := ctrl.UpdateAuthedUser(ctx, params) + require.Error(t, err) + assert.EqualError(t, err, fmt.Sprintf("failed to update authenticated user %s: update error", mUser.Username)) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) +} + +func TestControllerFollowUser(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + userRelationshipDBColumns, err := modeltest.ExtractDBColumns(db, model.UserRelationship{}) + require.NoError(t, err) + generateUserRelationshipDBRows, err := modeltest.NewDBRowsGenerator(db, model.UserRelationship{}) + require.NoError(t, err) + + t.Run("Normal", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mTargetUser := model.User{ + Model: model.Model{ID: 2}, + Username: "target-user", + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("username = ?", mTargetUser.Username). + First(&model.User{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(mTargetUser)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("user_id = ?", mUser.ID). + Where("target_user_id = ?", mTargetUser.ID). + First(&model.UserRelationship{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userRelationshipDBColumns)) + + dbMock.ExpectBegin() + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Create(&model.UserRelationship{UserID: 1, TargetUserID: 2}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[0] = sqlmock.AnyArg() + dbMockArgs[1] = sqlmock.AnyArg() + dbMockArgs[2] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(sqlmock.NewResult(1, 1)) + dbMock.ExpectCommit() + + dbMock.ExpectBegin() + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.UserRelationship{Model: model.Model{ID: 1}}). + Update("followed_at", sql.NullTime{Valid: true}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[0] = sqlmock.AnyArg() + dbMockArgs[1] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(sqlmock.NewResult(0, 1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.User{Model: mUser.Model}). + Update("following_count", gorm.Expr("following_count + 1")). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[0] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(sqlmock.NewResult(0, 1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.User{Model: mTargetUser.Model}). + Update("follower_count", gorm.Expr("follower_count + 1")). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[0] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(sqlmock.NewResult(0, 1)) + dbMock.ExpectCommit() + + err := ctrl.FollowUser(ctx, mTargetUser.Username) + require.NoError(t, err) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("AlreadyFollowing", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mTargetUser := model.User{ + Model: model.Model{ID: 2}, + Username: "target-user", + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("username = ?", mTargetUser.Username). + First(&model.User{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(mTargetUser)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("user_id = ?", mUser.ID). + Where("target_user_id = ?", mTargetUser.ID). + First(&model.UserRelationship{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userRelationshipDBColumns).AddRows(generateUserRelationshipDBRows( + model.UserRelationship{ + Model: model.Model{ID: 1}, + UserID: mUser.ID, + TargetUserID: mTargetUser.ID, + FollowedAt: sql.NullTime{Valid: true}, + }, + )...)) + + err := ctrl.FollowUser(ctx, mTargetUser.Username) + require.NoError(t, err) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("Unauthorized", func(t *testing.T) { + ctrl, _, closeDB := newTestController(t) + defer closeDB() + + err := ctrl.FollowUser(context.Background(), "target-user") + require.ErrorIs(t, err, ErrUnauthorized) + }) + + t.Run("SelfFollow", func(t *testing.T) { + ctrl, _, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + err := ctrl.FollowUser(ctx, mUser.Username) + require.ErrorIs(t, err, ErrBadRequest) + }) +} + +func TestControllerIsFollowingUser(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + + t.Run("Following", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mTargetUser := model.User{ + Model: model.Model{ID: 2}, + Username: "target-user", + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("username = ?", mTargetUser.Username). + First(&model.User{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(mTargetUser)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Select("id"). + Where("user_id = ?", mUser.ID). + Where("target_user_id = ?", mTargetUser.ID). + Where("followed_at IS NOT NULL"). + First(&model.UserRelationship{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + + isFollowing, err := ctrl.IsFollowingUser(ctx, mTargetUser.Username) + require.NoError(t, err) + assert.True(t, isFollowing) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("NotFollowing", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mTargetUser := model.User{ + Model: model.Model{ID: 2}, + Username: "target-user", + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("username = ?", mTargetUser.Username). + First(&model.User{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(mTargetUser)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Select("id"). + Where("user_id = ?", mUser.ID). + Where("target_user_id = ?", mTargetUser.ID). + Where("followed_at IS NOT NULL"). + First(&model.UserRelationship{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnError(gorm.ErrRecordNotFound) + + isFollowing, err := ctrl.IsFollowingUser(ctx, mTargetUser.Username) + require.NoError(t, err) + assert.False(t, isFollowing) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("Unauthorized", func(t *testing.T) { + ctrl, _, closeDB := newTestController(t) + defer closeDB() + + _, err := ctrl.IsFollowingUser(context.Background(), "target-user") + require.ErrorIs(t, err, ErrUnauthorized) + }) + + t.Run("SelfCheck", func(t *testing.T) { + ctrl, _, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + _, err := ctrl.IsFollowingUser(ctx, mUser.Username) + require.ErrorIs(t, err, ErrBadRequest) + }) +} + +func TestControllerUnfollowUser(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, model.User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, model.User{}) + require.NoError(t, err) + userRelationshipDBColumns, err := modeltest.ExtractDBColumns(db, model.UserRelationship{}) + require.NoError(t, err) + generateUserRelationshipDBRows, err := modeltest.NewDBRowsGenerator(db, model.UserRelationship{}) + require.NoError(t, err) + + t.Run("Normal", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mTargetUser := model.User{ + Model: model.Model{ID: 2}, + Username: "target-user", + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("username = ?", mTargetUser.Username). + First(&model.User{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(mTargetUser)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("user_id = ?", mUser.ID). + Where("target_user_id = ?", mTargetUser.ID). + First(&model.UserRelationship{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userRelationshipDBColumns).AddRows(generateUserRelationshipDBRows( + model.UserRelationship{ + Model: model.Model{ID: 1}, + UserID: mUser.ID, + TargetUserID: mTargetUser.ID, + FollowedAt: sql.NullTime{Valid: true, Time: sql.NullTime{}.Time}, + }, + )...)) + + dbMock.ExpectBegin() + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.UserRelationship{Model: model.Model{ID: 1}}). + Update("followed_at", sql.NullTime{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[0] = sqlmock.AnyArg() + dbMockArgs[1] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(sqlmock.NewResult(0, 1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.User{Model: mUser.Model}). + Update("following_count", gorm.Expr("following_count - 1")). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[0] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(sqlmock.NewResult(0, 1)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&model.User{Model: mTargetUser.Model}). + Update("follower_count", gorm.Expr("follower_count - 1")). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[0] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(sqlmock.NewResult(0, 1)) + dbMock.ExpectCommit() + + err := ctrl.UnfollowUser(ctx, mTargetUser.Username) + require.NoError(t, err) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("AlreadyNotFollowing", func(t *testing.T) { + ctrl, dbMock, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + mTargetUser := model.User{ + Model: model.Model{ID: 2}, + Username: "target-user", + } + + dbMockStmt := ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("username = ?", mTargetUser.Username). + First(&model.User{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(mTargetUser)...)) + + dbMockStmt = ctrl.db.Session(&gorm.Session{DryRun: true}). + Where("user_id = ?", mUser.ID). + Where("target_user_id = ?", mTargetUser.ID). + First(&model.UserRelationship{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userRelationshipDBColumns).AddRows(generateUserRelationshipDBRows( + model.UserRelationship{ + Model: model.Model{ID: 1}, + UserID: mUser.ID, + TargetUserID: mTargetUser.ID, + FollowedAt: sql.NullTime{Valid: false}, + }, + )...)) + + err := ctrl.UnfollowUser(ctx, mTargetUser.Username) + require.NoError(t, err) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("Unauthorized", func(t *testing.T) { + ctrl, _, closeDB := newTestController(t) + defer closeDB() + + err := ctrl.UnfollowUser(context.Background(), "target-user") + require.ErrorIs(t, err, ErrUnauthorized) + }) + + t.Run("SelfUnfollow", func(t *testing.T) { + ctrl, _, closeDB := newTestController(t) + defer closeDB() + + ctx := newContextWithTestUser(context.Background()) + mUser, ok := UserFromContext(ctx) + require.True(t, ok) + + err := ctrl.UnfollowUser(ctx, mUser.Username) + require.ErrorIs(t, err, ErrBadRequest) + }) +} diff --git a/spx-backend/internal/controller/util_test.go b/spx-backend/internal/controller/util_test.go index 14ef811ea..f46ff0ba7 100644 --- a/spx-backend/internal/controller/util_test.go +++ b/spx-backend/internal/controller/util_test.go @@ -28,8 +28,8 @@ func TestFmtCodeParamsValidate(t *testing.T) { func TestControllerFmtCode(t *testing.T) { t.Run("Normal", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) + ctrl, _, closeDB := newTestController(t) + closeDB() formattedCode, err := ctrl.FmtCode(context.Background(), &FmtCodeParams{ Body: "package main\n\nfunc main() {}\n", @@ -42,8 +42,8 @@ func TestControllerFmtCode(t *testing.T) { }) t.Run("FormatError", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) + ctrl, _, closeDB := newTestController(t) + closeDB() formattedCode, err := ctrl.FmtCode(context.Background(), &FmtCodeParams{ Body: "package main\n\nfunc main() {", @@ -58,10 +58,10 @@ func TestControllerFmtCode(t *testing.T) { }) t.Run("InvalidBody", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) + ctrl, _, closeDB := newTestController(t) + closeDB() - _, err = ctrl.FmtCode(context.Background(), &FmtCodeParams{ + _, err := ctrl.FmtCode(context.Background(), &FmtCodeParams{ Body: "-- prog.go --\n-- prog.go --", }) require.Error(t, err) @@ -71,8 +71,8 @@ func TestControllerFmtCode(t *testing.T) { func TestControllerGetUpInfo(t *testing.T) { t.Run("Normal", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) + ctrl, _, closeDB := newTestController(t) + closeDB() upInfo, err := ctrl.GetUpInfo(context.Background()) require.NoError(t, err) @@ -98,8 +98,8 @@ func TestMakeFileURLsParamsValidate(t *testing.T) { func TestControllerMakeFileURLs(t *testing.T) { t.Run("Normal", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) + ctrl, _, closeDB := newTestController(t) + closeDB() fileURLs, err := ctrl.MakeFileURLs(context.Background(), &MakeFileURLsParams{ Objects: []string{"kodo://builder/foo/bar"}, @@ -110,8 +110,8 @@ func TestControllerMakeFileURLs(t *testing.T) { }) t.Run("EmptyObjects", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) + ctrl, _, closeDB := newTestController(t) + closeDB() fileURLs, err := ctrl.MakeFileURLs(context.Background(), &MakeFileURLsParams{}) require.NoError(t, err) @@ -120,10 +120,10 @@ func TestControllerMakeFileURLs(t *testing.T) { }) t.Run("InvalidObject", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) + ctrl, _, closeDB := newTestController(t) + closeDB() - _, err = ctrl.MakeFileURLs(context.Background(), &MakeFileURLsParams{ + _, err := ctrl.MakeFileURLs(context.Background(), &MakeFileURLsParams{ Objects: []string{"://invalid"}, }) require.Error(t, err) @@ -131,10 +131,10 @@ func TestControllerMakeFileURLs(t *testing.T) { }) t.Run("UnrecognizedObject", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) + ctrl, _, closeDB := newTestController(t) + closeDB() - _, err = ctrl.MakeFileURLs(context.Background(), &MakeFileURLsParams{ + _, err := ctrl.MakeFileURLs(context.Background(), &MakeFileURLsParams{ Objects: []string{"not-kodo://builder/foo/bar"}, }) require.Error(t, err) @@ -142,11 +142,11 @@ func TestControllerMakeFileURLs(t *testing.T) { }) t.Run("URLJoinPathError", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) + ctrl, _, closeDB := newTestController(t) + closeDB() ctrl.kodo.baseUrl = "://invalid" - _, err = ctrl.MakeFileURLs(context.Background(), &MakeFileURLsParams{ + _, err := ctrl.MakeFileURLs(context.Background(), &MakeFileURLsParams{ Objects: []string{"kodo://builder/foo/bar"}, }) require.Error(t, err) diff --git a/spx-backend/internal/model/asset.go b/spx-backend/internal/model/asset.go index b592fbfcf..55028fbef 100644 --- a/spx-backend/internal/model/asset.go +++ b/spx-backend/internal/model/asset.go @@ -1,61 +1,40 @@ package model -import ( - "context" - "database/sql" - "fmt" - "time" - - "github.com/goplus/builder/spx-backend/internal/log" -) - -// Asset is the model for an asset. +// Asset is the model for assets. type Asset struct { - // ID is the globally unique identifier. - ID string `db:"id" json:"id"` - - // CTime is the creation time. - CTime time.Time `db:"c_time" json:"cTime"` - - // UTime is the last update time. - UTime time.Time `db:"u_time" json:"uTime"` + Model - // DisplayName is the name to display. - DisplayName string `db:"display_name" json:"displayName"` + // OwnerID is the ID of the asset owner. + OwnerID int64 `gorm:"column:owner_id;index"` + Owner User `gorm:"foreignKey:OwnerID"` - // Owner is the name of the asset owner. - Owner string `db:"owner" json:"owner"` + // DisplayName is the display name. + DisplayName string `gorm:"column:display_name;index:,class:FULLTEXT"` - // Category is the asset category. - Category string `db:"category" json:"category"` + // Type is the type of the asset. + Type AssetType `gorm:"column:type;index"` - // AssetType indicates the type of the asset. - AssetType AssetType `db:"asset_type" json:"assetType"` + // Category is the category to which the asset belongs. + Category string `gorm:"column:category;index"` - // Files contains the asset's files. - Files FileCollection `db:"files" json:"files"` + // Files contains the file paths and their corresponding universal URLs + // associated with the asset. + Files FileCollection `gorm:"column:files"` - // FilesHash is the hash of the asset's files. - FilesHash string `db:"files_hash" json:"filesHash"` + // FilesHash is the hash of the asset files. + FilesHash string `gorm:"column:files_hash"` - // Preview is the URL for the asset preview, e.g., a gif for a sprite. - Preview string `db:"preview" json:"preview"` - - // ClickCount is the number of clicks on the asset. - ClickCount int64 `db:"click_count" json:"clickCount"` - - // IsPublic indicates if the asset is public. - IsPublic IsPublic `db:"is_public" json:"isPublic"` - - // Status indicates if the asset is deleted. - Status Status `db:"status" json:"status"` + // Visibility is the visibility. + Visibility Visibility `gorm:"column:visibility;index"` } -// TableAsset is the table name of [Asset] in database. -const TableAsset = "asset" +// TableName implements [gorm.io/gorm/schema.Tabler]. +func (Asset) TableName() string { + return "asset" +} // AssetType is the type of asset. -type AssetType int +type AssetType uint8 const ( AssetTypeSprite AssetType = iota @@ -63,53 +42,29 @@ const ( AssetTypeSound ) -// AssetByID gets asset with given id. Returns `ErrNotExist` if it does not exist. -func AssetByID(ctx context.Context, db *sql.DB, id string) (*Asset, error) { - return QueryByID[Asset](ctx, db, TableAsset, id) -} - -// ListAssets lists assets with given pagination, where conditions and order by conditions. -func ListAssets(ctx context.Context, db *sql.DB, paginaton Pagination, filters []FilterCondition, orderBy []OrderByCondition) (*ByPage[Asset], error) { - return QueryByPage[Asset](ctx, db, TableAsset, paginaton, filters, orderBy) -} - -// AddAsset adds an asset. -func AddAsset(ctx context.Context, db *sql.DB, a *Asset) (*Asset, error) { - return Create(ctx, db, TableAsset, a) -} - -// UpdateAssetByID updates asset with given id. -func UpdateAssetByID(ctx context.Context, db *sql.DB, id string, a *Asset) (*Asset, error) { - logger := log.GetReqLogger(ctx) - if err := UpdateByID(ctx, db, TableAsset, id, a, "display_name", "category", "asset_type", "files", "files_hash", "preview", "is_public"); err != nil { - logger.Printf("UpdateByID failed: %v", err) - return nil, err +// ParseAssetType parses the string representation of the asset type. +func ParseAssetType(s string) AssetType { + switch s { + case "sprite": + return AssetTypeSprite + case "backdrop": + return AssetTypeBackdrop + case "sound": + return AssetTypeSound } - return AssetByID(ctx, db, id) + return 255 } -// IncreaseAssetClickCount increases asset's click count by 1. -func IncreaseAssetClickCount(ctx context.Context, db *sql.DB, id string) error { - logger := log.GetReqLogger(ctx) - - query := fmt.Sprintf("UPDATE %s SET u_time = ?, click_count = click_count + 1 WHERE id = ?", TableAsset) - result, err := db.ExecContext(ctx, query, time.Now().UTC(), id) - if err != nil { - logger.Printf("db.ExecContext failed: %v", err) - return err - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - logger.Printf("result.RowsAffected failed: %v", err) - return err - } else if rowsAffected == 0 { - return ErrNotExist +// String implements [fmt.Stringer]. It returns the string representation of the +// asset type. +func (at AssetType) String() string { + switch at { + case AssetTypeSprite: + return "sprite" + case AssetTypeBackdrop: + return "backdrop" + case AssetTypeSound: + return "sound" } - return nil -} - -// DeleteAssetByID deletes asset with given id. -func DeleteAssetByID(ctx context.Context, db *sql.DB, id string) error { - return UpdateByID(ctx, db, TableAsset, id, &Asset{Status: StatusDeleted}, "status") + return "unknown" } diff --git a/spx-backend/internal/model/asset_test.go b/spx-backend/internal/model/asset_test.go deleted file mode 100644 index 5ba5d33eb..000000000 --- a/spx-backend/internal/model/asset_test.go +++ /dev/null @@ -1,218 +0,0 @@ -package model - -import ( - "context" - "database/sql" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAssetByID(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"display_name"}). - AddRow("foo")) - asset, err := AssetByID(context.Background(), db, "1") - require.NoError(t, err) - require.NotNil(t, asset) - assert.Equal(t, "foo", asset.DisplayName) - }) - - t.Run("NotExist", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - asset, err := AssetByID(context.Background(), db, "1") - require.Error(t, err) - assert.ErrorIs(t, err, ErrNotExist) - assert.Nil(t, asset) - }) -} - -func TestListAssets(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM asset WHERE status != \?`). - WillReturnRows(mock.NewRows([]string{"COUNT(*)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM asset WHERE status != \? ORDER BY id ASC LIMIT \?, \?`). - WillReturnRows(mock.NewRows([]string{"display_name"}). - AddRow("foo")) - assets, err := ListAssets(context.Background(), db, Pagination{Index: 1, Size: 1}, nil, nil) - require.NoError(t, err) - require.NotNil(t, assets) - assert.Equal(t, 1, assets.Total) - assert.Len(t, assets.Data, 1) - assert.Equal(t, "foo", assets.Data[0].DisplayName) - }) - - t.Run("ClosedConnForCountQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM asset WHERE status != \?`). - WillReturnError(sql.ErrConnDone) - assets, err := ListAssets(context.Background(), db, Pagination{Index: 1, Size: 1}, nil, nil) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, assets) - }) -} - -func TestAddAsset(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`INSERT INTO asset \(.+\) VALUES \(\?,\?,\?,\?,\?,\?,\?,\?,\?,\?,\?,\?\)`). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"display_name"}). - AddRow("foo")) - asset, err := AddAsset(context.Background(), db, &Asset{DisplayName: "foo"}) - require.NoError(t, err) - require.NotNil(t, asset) - assert.Equal(t, "foo", asset.DisplayName) - }) - - t.Run("ClosedConnForInsertQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`INSERT INTO asset \(.+\) VALUES \(\?,\?,\?,\?,\?,\?,\?,\?,\?,\?,\?,\?\)`). - WillReturnError(sql.ErrConnDone) - asset, err := AddAsset(context.Background(), db, &Asset{DisplayName: "foo"}) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, asset) - }) -} - -func TestUpdateAssetByID(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE asset SET u_time=\?,display_name=\?,category=\?,asset_type=\?,files=\?,files_hash=\?,preview=\?,is_public=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), "foo", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"display_name"}). - AddRow("foo")) - asset, err := UpdateAssetByID(context.Background(), db, "1", &Asset{DisplayName: "foo"}) - require.NoError(t, err) - require.NotNil(t, asset) - assert.Equal(t, "foo", asset.DisplayName) - }) - - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE asset SET u_time=\?,display_name=\?,category=\?,asset_type=\?,files=\?,files_hash=\?,preview=\?,is_public=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), "foo", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "1"). - WillReturnError(sql.ErrConnDone) - asset, err := UpdateAssetByID(context.Background(), db, "1", &Asset{DisplayName: "foo"}) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, asset) - }) -} - -func TestIncreaseAssetClickCount(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE asset SET u_time = \?, click_count = click_count \+ 1 WHERE id = \?`). - WithArgs(sqlmock.AnyArg(), "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - err = IncreaseAssetClickCount(context.Background(), db, "1") - require.NoError(t, err) - }) - - t.Run("NotExist", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE asset SET u_time = \?, click_count = click_count \+ 1 WHERE id = \?`). - WithArgs(sqlmock.AnyArg(), "1"). - WillReturnResult(sqlmock.NewResult(1, 0)) - err = IncreaseAssetClickCount(context.Background(), db, "1") - require.Error(t, err) - assert.ErrorIs(t, err, ErrNotExist) - }) - - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE asset SET u_time = \?, click_count = click_count \+ 1 WHERE id = \?`). - WithArgs(sqlmock.AnyArg(), "1"). - WillReturnError(sql.ErrConnDone) - err = IncreaseAssetClickCount(context.Background(), db, "1") - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) - - t.Run("ClosedConnForRowsAffected", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE asset SET u_time = \?, click_count = click_count \+ 1 WHERE id = \?`). - WithArgs(sqlmock.AnyArg(), "1"). - WillReturnResult(sqlmock.NewErrorResult(sql.ErrConnDone)) - err = IncreaseAssetClickCount(context.Background(), db, "1") - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} - -func TestDeleteAssetByID(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE asset SET u_time=\?,status=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), StatusDeleted, "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - err = DeleteAssetByID(context.Background(), db, "1") - require.NoError(t, err) - }) - - t.Run("ClosedConnForDeleteQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE asset SET u_time=\?,status=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), StatusDeleted, "1"). - WillReturnError(sql.ErrConnDone) - err = DeleteAssetByID(context.Background(), db, "1") - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} diff --git a/spx-backend/internal/model/clause.go b/spx-backend/internal/model/clause.go deleted file mode 100644 index 008ec335d..000000000 --- a/spx-backend/internal/model/clause.go +++ /dev/null @@ -1,65 +0,0 @@ -package model - -import ( - "fmt" - "strings" -) - -// FilterCondition represents a condition to filter rows. -type FilterCondition struct { - Column string // column name - Operation string // "=", "<", "!=" ... - Value any // value -} - -// Expr returns the expression of the condition for use in a parameterized query. -func (cond *FilterCondition) Expr() string { - return fmt.Sprintf("%s %s ?", cond.Column, cond.Operation) -} - -// buildWhereClause builds a WHERE clause from the given conditions. -// -// The deleted items are filtered out by default. -func buildWhereClause(conds []FilterCondition) (string, []any) { - var ( - exprs = make([]string, 0, len(conds)+1) - args = make([]any, 0, len(conds)+1) - ) - for _, cond := range conds { - exprs = append(exprs, cond.Expr()) - args = append(args, cond.Value) - } - - // Filter out deleted items. - exprs = append(exprs, "status != ?") - args = append(args, StatusDeleted) - - whereClause := "WHERE " + strings.Join(exprs, " AND ") - return whereClause, args -} - -// OrderByCondition represents a condition to order rows. -type OrderByCondition struct { - Column string // column name - Direction string // ASC or DESC -} - -// Expr returns the expression of the condition for use in a parameterized query. -func (cond *OrderByCondition) Expr() string { - return fmt.Sprintf("%s %s", cond.Column, cond.Direction) -} - -// buildOrderByClause builds an ORDER BY clause from the given conditions. -// -// If no conditions are given, the default order is by ID in ascending order. -func buildOrderByClause(conds []OrderByCondition) string { - if len(conds) == 0 { - return "ORDER BY id ASC" - } - exprs := make([]string, 0, len(conds)) - for _, cond := range conds { - exprs = append(exprs, cond.Expr()) - } - orderByClause := "ORDER BY " + strings.Join(exprs, ", ") - return orderByClause -} diff --git a/spx-backend/internal/model/clause_test.go b/spx-backend/internal/model/clause_test.go deleted file mode 100644 index a44c0d049..000000000 --- a/spx-backend/internal/model/clause_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package model - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestFilterConditionExpr(t *testing.T) { - t.Run("Equal", func(t *testing.T) { - cond := FilterCondition{"a", "=", 1} - assert.Equal(t, "a = ?", cond.Expr()) - }) - - t.Run("Empty", func(t *testing.T) { - cond := FilterCondition{} - assert.Equal(t, " ?", cond.Expr()) - }) -} - -func TestBuildWhereClause(t *testing.T) { - t.Run("Nil", func(t *testing.T) { - clause, args := buildWhereClause(nil) - assert.Equal(t, "WHERE status != ?", clause) - assert.Equal(t, []any{StatusDeleted}, args) - }) - - t.Run("OneCondition", func(t *testing.T) { - clause, args := buildWhereClause([]FilterCondition{ - {"a", "=", 1}, - }) - assert.Equal(t, "WHERE a = ? AND status != ?", clause) - assert.Equal(t, []any{1, StatusDeleted}, args) - }) - - t.Run("MultipleConditions", func(t *testing.T) { - clause, args := buildWhereClause([]FilterCondition{ - {"a", "=", 1}, - {"b", "!=", 2}, - }) - assert.Equal(t, "WHERE a = ? AND b != ? AND status != ?", clause) - assert.Equal(t, []any{1, 2, StatusDeleted}, args) - }) -} - -func TestOrderByConditionExpr(t *testing.T) { - t.Run("ASC", func(t *testing.T) { - cond := OrderByCondition{"a", "ASC"} - assert.Equal(t, "a ASC", cond.Expr()) - }) - - t.Run("Empty", func(t *testing.T) { - cond := OrderByCondition{} - assert.Equal(t, " ", cond.Expr()) - }) -} - -func TestBuildOrderByClause(t *testing.T) { - t.Run("Nil", func(t *testing.T) { - clause := buildOrderByClause(nil) - assert.Equal(t, "ORDER BY id ASC", clause) - }) - - t.Run("OneCondition", func(t *testing.T) { - clause := buildOrderByClause([]OrderByCondition{ - {"a", "ASC"}, - }) - assert.Equal(t, "ORDER BY a ASC", clause) - }) - - t.Run("MultipleConditions", func(t *testing.T) { - clause := buildOrderByClause([]OrderByCondition{ - {"a", "ASC"}, - {"b", "DESC"}, - }) - assert.Equal(t, "ORDER BY a ASC, b DESC", clause) - }) -} diff --git a/spx-backend/internal/model/file.go b/spx-backend/internal/model/file.go deleted file mode 100644 index 667e1c746..000000000 --- a/spx-backend/internal/model/file.go +++ /dev/null @@ -1,34 +0,0 @@ -package model - -import ( - "database/sql/driver" - "encoding/json" - "errors" - "fmt" -) - -// FileCollection is a map from relative path to universal URL. It is used to -// store files information for [Projetc] and [Asset]. -type FileCollection map[string]string - -// Scan implements [sql.Scanner]. -func (fc *FileCollection) Scan(src any) error { - switch src := src.(type) { - case []byte: - var parsed FileCollection - if err := json.Unmarshal(src, &parsed); err != nil { - return fmt.Errorf("failed to unmarshal FileCollection: %w", err) - } - *fc = parsed - case nil: - *fc = FileCollection{} - default: - return errors.New("incompatible type for FileCollection") - } - return nil -} - -// Value implements [driver.Valuer]. -func (fc FileCollection) Value() (driver.Value, error) { - return json.Marshal(fc) -} diff --git a/spx-backend/internal/model/model.go b/spx-backend/internal/model/model.go index a87ca6d64..255abb396 100644 --- a/spx-backend/internal/model/model.go +++ b/spx-backend/internal/model/model.go @@ -1,24 +1,103 @@ package model -import "errors" +import ( + "context" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "time" -var ( - ErrExist = errors.New("item already existed") - ErrNotExist = errors.New("item does not exist") + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" ) -// IsPublic indicates the visibility of an item. -type IsPublic int +// Model is the base model for all models. +type Model struct { + ID int64 `gorm:"column:id;autoIncrement;primaryKey"` + CreatedAt time.Time `gorm:"column:created_at;not null"` + UpdatedAt time.Time `gorm:"column:updated_at;not null"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index"` +} -const ( - Personal IsPublic = iota - Public -) +// OpenDB opens the database with the given dsn and models to be migrated. +func OpenDB(ctx context.Context, dsn string, maxOpenConns, maxIdleConns int, models ...any) (*gorm.DB, error) { + dialector := mysql.New(mysql.Config{DSN: dsn}) + db, err := gorm.Open(dialector, &gorm.Config{ + Logger: logger.Discard, + NowFunc: func() time.Time { return time.Now().UTC() }, + }) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("failed to get database/sql.DB: %w", err) + } + sqlDB.SetMaxOpenConns(maxOpenConns) + sqlDB.SetMaxIdleConns(maxIdleConns) + if len(models) > 0 { + if err := db.WithContext(ctx).AutoMigrate(models...); err != nil { + return nil, fmt.Errorf("failed to auto migrate models: %w", err) + } + } + return db, nil +} -// Status indicates the status of an item. -type Status int +// Visibility indicates the visibility of an item. +type Visibility uint8 const ( - StatusDeleted Status = iota - StatusNormal + VisibilityPrivate Visibility = iota + VisibilityPublic ) + +// ParseVisibility parses the string representation of the visibility. +func ParseVisibility(s string) Visibility { + switch s { + case "private": + return VisibilityPrivate + case "public": + return VisibilityPublic + } + return 255 +} + +// String implements [fmt.Stringer]. It returns the string representation of the +// visibility. +func (v Visibility) String() string { + switch v { + case VisibilityPrivate: + return "private" + case VisibilityPublic: + return "public" + } + return "unknown" +} + +// FileCollection is a map from relative path to universal URL. It is used to +// store files information. +type FileCollection map[string]string + +// Scan implements [sql.Scanner]. +func (fc *FileCollection) Scan(src any) error { + switch src := src.(type) { + case []byte: + var parsed FileCollection + if err := json.Unmarshal(src, &parsed); err != nil { + return fmt.Errorf("failed to unmarshal FileCollection: %w", err) + } + *fc = parsed + case nil: + *fc = FileCollection{} + default: + return errors.New("incompatible type for FileCollection") + } + return nil +} + +// Value implements [driver.Valuer]. +func (fc FileCollection) Value() (driver.Value, error) { + return json.Marshal(fc) +} diff --git a/spx-backend/internal/model/file_test.go b/spx-backend/internal/model/model_test.go similarity index 100% rename from spx-backend/internal/model/file_test.go rename to spx-backend/internal/model/model_test.go diff --git a/spx-backend/internal/model/modeltest/modeltest.go b/spx-backend/internal/model/modeltest/modeltest.go new file mode 100644 index 000000000..0402e8c5e --- /dev/null +++ b/spx-backend/internal/model/modeltest/modeltest.go @@ -0,0 +1,91 @@ +package modeltest + +import ( + "context" + "database/sql/driver" + "reflect" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// NewMockDB creates a new [gorm.DB] instance with a mocked database connection. +func NewMockDB() (db *gorm.DB, dbMock sqlmock.Sqlmock, closeDB func() error, err error) { + mockDB, dbMock, err := sqlmock.New() + if err != nil { + return nil, nil, nil, err + } + defer func() { + if err != nil { + mockDB.Close() + } + }() + dialector := mysql.New(mysql.Config{Conn: mockDB, SkipInitializeWithVersion: true}) + db, err = gorm.Open(dialector, &gorm.Config{ + Logger: logger.Discard, + NowFunc: func() time.Time { return time.Now().UTC() }, + }) + if err != nil { + return nil, nil, nil, err + } + return db, dbMock, mockDB.Close, nil +} + +// ExtractDBColumns returns the database column names for a given GORM model. +func ExtractDBColumns(db *gorm.DB, model any) ([]string, error) { + stmt := &gorm.Statement{DB: db} + if err := stmt.Parse(model); err != nil { + return nil, err + } + var columns []string + for _, field := range stmt.Schema.Fields { + if field.DBName == "" { + continue + } + columns = append(columns, field.DBName) + } + return columns, nil +} + +// NewDBRowsGenerator returns a function that generates database rows for given +// GORM model instances. +// +// The returned rows will be in the same order as the columns returned by the +// [ExtractModelColumns]. +func NewDBRowsGenerator[T any](db *gorm.DB, model T) (func(modelInstances ...T) [][]driver.Value, error) { + stmt := &gorm.Statement{DB: db} + if err := stmt.Parse(model); err != nil { + return nil, err + } + return func(modelInstances ...T) [][]driver.Value { + var rows [][]driver.Value + for _, mi := range modelInstances { + var row []driver.Value + reflectValue := reflect.ValueOf(mi) + if reflectValue.Kind() == reflect.Ptr { + reflectValue = reflectValue.Elem() + } + for _, field := range stmt.Schema.Fields { + if field.DBName == "" { + continue + } + value, _ := field.ValueOf(context.Background(), reflectValue) + row = append(row, value) + } + rows = append(rows, row) + } + return rows + }, nil +} + +// ToDriverValueSlice converts a list of values to a list of driver values. +func ToDriverValueSlice(values ...any) []driver.Value { + driverValues := make([]driver.Value, len(values)) + for i, v := range values { + driverValues[i] = v + } + return driverValues +} diff --git a/spx-backend/internal/model/project.go b/spx-backend/internal/model/project.go index 2c9604365..d14060168 100644 --- a/spx-backend/internal/model/project.go +++ b/spx-backend/internal/model/project.go @@ -1,91 +1,59 @@ package model -import ( - "context" - "database/sql" - "errors" - "time" +import "database/sql" - "github.com/goplus/builder/spx-backend/internal/log" -) - -// Project is the model for a project. +// Project is the model for projects. type Project struct { - // ID is the globally unique identifier. - ID string `db:"id" json:"id"` + Model - // CTime is the creation time. - CTime time.Time `db:"c_time" json:"cTime"` + // OwnerID is the ID of the project owner. + OwnerID int64 `gorm:"column:owner_id;index;index:,composite:owner_id_name,unique,where:deleted_at IS NULL"` + Owner User `gorm:"foreignKey:OwnerID"` - // UTime is the last update time. - UTime time.Time `db:"u_time" json:"uTime"` + // RemixedFromReleaseID is the ID of the project release from which the + // project is remixed. + // + // If RemixedFromReleaseID.Valid is false, it means the project is not a + // remix. + RemixedFromReleaseID sql.NullInt64 `gorm:"column:remixed_from_release_id;index"` + RemixedFromRelease *ProjectRelease `gorm:"foreignKey:RemixedFromReleaseID"` - // Name is the project's name, unique for projects of the same owner. - Name string `db:"name" json:"name"` + // Name is the unique name. + Name string `gorm:"column:name;index:,class:FULLTEXT;index:,composite:owner_id_name,unique,where:deleted_at IS NULL"` - // Owner is the name of the project owner. - Owner string `db:"owner" json:"owner"` + // Version is the version number. + Version int `gorm:"column:version"` - // Version is the project version. - Version int `db:"version" json:"version"` + // Files contains the file paths and their corresponding universal URLs + // associated with the project. + Files FileCollection `gorm:"column:files"` - // Files contains the project's files. - Files FileCollection `db:"files" json:"files"` + // Visibility is the visibility. + Visibility Visibility `gorm:"column:visibility;index"` - // IsPublic indicates if the project is public. - IsPublic IsPublic `db:"is_public" json:"isPublic"` + // Description is the brief description. + Description string `gorm:"column:description"` - // Status indicates if the project is deleted. - Status Status `db:"status" json:"status"` -} + // Instructions is the instructions on how to interact with the project. + Instructions string `gorm:"column:instructions"` -// TableProject is the table name of [Project] in database. -const TableProject = "project" + // Thumbnail is the URL of the thumbnail image. + Thumbnail string `gorm:"column:thumbnail"` -// ProjectByID gets project with given id. Returns `ErrNotExist` if it does not exist. -func ProjectByID(ctx context.Context, db *sql.DB, id string) (*Project, error) { - return QueryByID[Project](ctx, db, TableProject, id) -} + // ViewCount is the number of times the project has been viewed. + ViewCount int64 `gorm:"column:view_count;index"` -// ProjectByOwnerAndName gets project with given owner and name. Returns `ErrNotExist` if it does not exist -func ProjectByOwnerAndName(ctx context.Context, db *sql.DB, owner string, name string) (*Project, error) { - where := []FilterCondition{ - {Column: "owner", Operation: "=", Value: owner}, - {Column: "name", Operation: "=", Value: name}, - } - return QueryFirst[Project](ctx, db, TableProject, where, nil) -} + // LikeCount is the number of times the project has been liked. + LikeCount int64 `gorm:"column:like_count;index"` -// ListProjects lists projects with given pagination, where conditions and order by conditions. -func ListProjects(ctx context.Context, db *sql.DB, paginaton Pagination, where []FilterCondition, orderBy []OrderByCondition) (*ByPage[Project], error) { - return QueryByPage[Project](ctx, db, TableProject, paginaton, where, orderBy) -} - -// AddProject adds a project. -func AddProject(ctx context.Context, db *sql.DB, p *Project) (*Project, error) { - logger := log.GetReqLogger(ctx) - - if _, err := ProjectByOwnerAndName(ctx, db, p.Owner, p.Name); err == nil { - return nil, ErrExist - } else if !errors.Is(err, ErrNotExist) { - logger.Printf("ProjectByOwnerAndName failed: %v", err) - return nil, err - } - - return Create(ctx, db, TableProject, p) -} + // ReleaseCount is the number of releases associated with the project. + ReleaseCount int64 `gorm:"column:release_count;index"` -// UpdateProjectByID updates project with given id. -func UpdateProjectByID(ctx context.Context, db *sql.DB, id string, p *Project) (*Project, error) { - logger := log.GetReqLogger(ctx) - if err := UpdateByID(ctx, db, TableProject, id, p, "version", "files", "is_public"); err != nil { - logger.Printf("UpdateByID failed: %v", err) - return nil, err - } - return ProjectByID(ctx, db, id) + // RemixCount is the number of times the project has been remixed. + RemixCount int64 `gorm:"column:remix_count;index"` } -// DeleteProjectByID deletes project with given id. -func DeleteProjectByID(ctx context.Context, db *sql.DB, id string) error { - return UpdateByID(ctx, db, TableProject, id, &Project{Status: StatusDeleted}, "status") +// TableName implements [gorm.io/gorm/schema.Tabler]. +func (Project) TableName() string { + return "project" } diff --git a/spx-backend/internal/model/project_release.go b/spx-backend/internal/model/project_release.go new file mode 100644 index 000000000..1eab44fc3 --- /dev/null +++ b/spx-backend/internal/model/project_release.go @@ -0,0 +1,31 @@ +package model + +// ProjectRelease is the model for project releases. +type ProjectRelease struct { + Model + + // ProjectID is the ID of the project that the release is associated with. + ProjectID int64 `gorm:"column:project_id;index;index:,composite:project_id_name,unique"` + Project Project `gorm:"foreignKey:ProjectID"` + + // Name is the unique name of the project release. + Name string `gorm:"column:name;index:,class:FULLTEXT;index:,composite:project_id_name,unique"` + + // Description is the brief description. + Description string `gorm:"column:description"` + + // Files contains file paths and their corresponding universal URLs + // associated with the release. + Files FileCollection `gorm:"column:files"` + + // Thumbnail is the URL of the thumbnail image. + Thumbnail string `gorm:"column:thumbnail"` + + // RemixCount is the number of times the release has been remixed. + RemixCount int64 `gorm:"column:remix_count;index"` +} + +// TableName implements [gorm.io/gorm/schema.Tabler]. +func (ProjectRelease) TableName() string { + return "project_release" +} diff --git a/spx-backend/internal/model/project_test.go b/spx-backend/internal/model/project_test.go deleted file mode 100644 index 6008ea222..000000000 --- a/spx-backend/internal/model/project_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package model - -import ( - "context" - "database/sql" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestProjectByID(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM project WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"name"}). - AddRow("foo")) - project, err := ProjectByID(context.Background(), db, "1") - require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "foo", project.Name) - }) - - t.Run("NotExist", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM project WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - project, err := ProjectByID(context.Background(), db, "1") - require.Error(t, err) - assert.ErrorIs(t, err, ErrNotExist) - assert.Nil(t, project) - }) -} - -func TestProjectByOwnerAndName(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"name"}). - AddRow("foo")) - project, err := ProjectByOwnerAndName(context.Background(), db, "owner", "name") - require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "foo", project.Name) - }) - - t.Run("NotExist", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - project, err := ProjectByOwnerAndName(context.Background(), db, "owner", "name") - require.Error(t, err) - assert.ErrorIs(t, err, ErrNotExist) - assert.Nil(t, project) - }) -} - -func TestListProjects(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM project WHERE status != \?`). - WillReturnRows(mock.NewRows([]string{"COUNT(*)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM project WHERE status != \? ORDER BY id ASC LIMIT \?, \?`). - WillReturnRows(mock.NewRows([]string{"name"}). - AddRow("foo")) - projects, err := ListProjects(context.Background(), db, Pagination{Index: 1, Size: 1}, nil, nil) - require.NoError(t, err) - require.NotNil(t, projects) - assert.Equal(t, 1, projects.Total) - assert.Len(t, projects.Data, 1) - assert.Equal(t, "foo", projects.Data[0].Name) - }) - - t.Run("ClosedConnForCountQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM project WHERE status != \?`). - WillReturnError(sql.ErrConnDone) - projects, err := ListProjects(context.Background(), db, Pagination{Index: 1, Size: 1}, nil, nil) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, projects) - }) -} - -func TestAddProject(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - mock.ExpectExec(`INSERT INTO project \(.+\) VALUES \(\?,\?,\?,\?,\?,\?,\?,\?\)`). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT \* FROM project WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"name"}). - AddRow("foo")) - project, err := AddProject(context.Background(), db, &Project{Name: "foo", Owner: "owner"}) - require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "foo", project.Name) - }) - - t.Run("Exist", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"name"}). - AddRow("foo")) - project, err := AddProject(context.Background(), db, &Project{Name: "foo", Owner: "owner"}) - require.Error(t, err) - assert.ErrorIs(t, err, ErrExist) - assert.Nil(t, project) - }) - - t.Run("ClosedConnForProjectByOwnerAndName", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnError(sql.ErrConnDone) - project, err := AddProject(context.Background(), db, &Project{Name: "foo", Owner: "owner"}) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, project) - }) - - t.Run("ClosedConnForInsertQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - mock.ExpectExec(`INSERT INTO project \(.+\) VALUES \(\?,\?,\?,\?,\?,\?,\?,\?\)`). - WillReturnError(sql.ErrConnDone) - project, err := AddProject(context.Background(), db, &Project{Name: "foo", Owner: "owner"}) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, project) - }) -} - -func TestUpdateProjectByID(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE project SET u_time=\?,version=\?,files=\?,is_public=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), 2, sqlmock.AnyArg(), sqlmock.AnyArg(), "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT \* FROM project WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"name"}). - AddRow("foo")) - project, err := UpdateProjectByID(context.Background(), db, "1", &Project{Version: 2}) - require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "foo", project.Name) - }) - - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE project SET u_time=\?,version=\?,files=\?,is_public=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), 2, sqlmock.AnyArg(), sqlmock.AnyArg(), "1"). - WillReturnError(sql.ErrConnDone) - project, err := UpdateProjectByID(context.Background(), db, "1", &Project{Version: 2}) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, project) - }) -} - -func TestDeleteProjectByID(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE project SET u_time=\?,status=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), StatusDeleted, "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - err = DeleteProjectByID(context.Background(), db, "1") - require.NoError(t, err) - }) - - t.Run("ClosedConnForDeleteQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE project SET u_time=\?,status=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), StatusDeleted, "1"). - WillReturnError(sql.ErrConnDone) - err = DeleteProjectByID(context.Background(), db, "1") - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} diff --git a/spx-backend/internal/model/query.go b/spx-backend/internal/model/query.go deleted file mode 100644 index 7880f7606..000000000 --- a/spx-backend/internal/model/query.go +++ /dev/null @@ -1,220 +0,0 @@ -package model - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strconv" - "strings" - "time" - - "github.com/goplus/builder/spx-backend/internal/log" -) - -// Query queries a table. -func Query[T any](ctx context.Context, db *sql.DB, table string, where []FilterCondition, orderBy []OrderByCondition) ([]T, error) { - logger := log.GetReqLogger(ctx) - - whereClause, whereArgs := buildWhereClause(where) - orderByClause := buildOrderByClause(orderBy) - - query := fmt.Sprintf("SELECT * FROM %s %s %s", table, whereClause, orderByClause) - rows, err := db.QueryContext(ctx, query, whereArgs...) - if err != nil { - logger.Printf("db.QueryContext failed: %v", err) - return nil, err - } - defer rows.Close() - - var items []T - for rows.Next() { - item, err := rowsScan[T](rows) - if err != nil { - logger.Printf("rowsScan failed: %v", err) - return nil, err - } - items = append(items, item) - } - return items, nil -} - -// Pagination is the pagination information. -type Pagination struct { - Index int - Size int -} - -// ByPage is a generic struct for paginated data. -type ByPage[T any] struct { - Total int `json:"total"` - Data []T `json:"data"` -} - -// QueryByPage queries a table by page. -func QueryByPage[T any](ctx context.Context, db *sql.DB, table string, paginaton Pagination, where []FilterCondition, orderBy []OrderByCondition) (*ByPage[T], error) { - logger := log.GetReqLogger(ctx) - - whereClause, whereArgs := buildWhereClause(where) - orderByClause := buildOrderByClause(orderBy) - - var total int - countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s %s", table, whereClause) - if err := db.QueryRowContext(ctx, countQuery, whereArgs...).Scan(&total); err != nil { - logger.Printf("db.QueryRowContext failed: %v", err) - return nil, err - } - - offset := (paginaton.Index - 1) * paginaton.Size - query := fmt.Sprintf("SELECT * FROM %s %s %s LIMIT ?, ?", table, whereClause, orderByClause) - args := append(whereArgs, offset, paginaton.Size) - rows, err := db.QueryContext(ctx, query, args...) - if err != nil { - logger.Printf("db.QueryContext failed: %v", err) - return nil, err - } - defer rows.Close() - - data := make([]T, 0, paginaton.Size) - for rows.Next() { - item, err := rowsScan[T](rows) - if err != nil { - logger.Printf("rowsScan failed: %v", err) - return nil, err - } - data = append(data, item) - } - - return &ByPage[T]{ - Total: total, - Data: data, - }, nil -} - -// QueryFirst queries a table and returns the first result. Returns [ErrNotExist] if it does not exist. -func QueryFirst[T any](ctx context.Context, db *sql.DB, table string, where []FilterCondition, orderBy []OrderByCondition) (*T, error) { - logger := log.GetReqLogger(ctx) - - whereClause, whereArgs := buildWhereClause(where) - orderByClause := buildOrderByClause(orderBy) - - query := fmt.Sprintf("SELECT * FROM %s %s %s LIMIT 1", table, whereClause, orderByClause) - rows, err := db.QueryContext(ctx, query, whereArgs...) - if err != nil { - logger.Printf("db.QueryContext failed: %v", err) - return nil, err - } - defer rows.Close() - - if !rows.Next() { - return nil, ErrNotExist - } - item, err := rowsScan[T](rows) - if err != nil { - logger.Printf("rowsScan failed: %v", err) - return nil, err - } - return &item, nil -} - -// QueryByID queries an item by ID. Returns [ErrNotExist] if it does not exist. -func QueryByID[T any](ctx context.Context, db *sql.DB, table string, id string) (*T, error) { - where := []FilterCondition{{Column: "id", Operation: "=", Value: id}} - return QueryFirst[T](ctx, db, table, where, nil) -} - -// Create creates an item. -func Create[T any](ctx context.Context, db *sql.DB, table string, item *T) (*T, error) { - logger := log.GetReqLogger(ctx) - - itemValue, dbFields, err := reflectModelItem(item) - if err != nil { - logger.Printf("failed to reflect model item: %v", err) - return nil, err - } - if len(dbFields) == 0 { - return nil, errors.New("no db fields found") - } - - now := time.Now().UTC() - columns := make([]string, 0, len(dbFields)) - values := make([]any, 0, len(dbFields)) - for dbTag, dbField := range dbFields { - var value any - switch dbTag { - case "id": - // Skip id column since it's supposed to be auto-generated. - continue - case "c_time", "u_time": - value = now - case "status": - value = StatusNormal - default: - value = itemValue.FieldByIndex(dbField.Index).Interface() - } - columns = append(columns, dbTag) - values = append(values, value) - } - - joinedColumns := strings.Join(columns, ",") - joinedPlaceholders := strings.Repeat(",?", len(columns))[1:] - query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, joinedColumns, joinedPlaceholders) - - result, err := db.ExecContext(ctx, query, values...) - if err != nil { - logger.Printf("db.ExecContext failed: %v", err) - return nil, err - } - - id, err := result.LastInsertId() - if err != nil { - logger.Printf("failed to get last insert id: %v", err) - return nil, err - } - return QueryByID[T](ctx, db, table, strconv.FormatInt(id, 10)) -} - -// UpdateByID updates an item by ID. -func UpdateByID[T any](ctx context.Context, db *sql.DB, table string, id string, item *T, columns ...string) error { - logger := log.GetReqLogger(ctx) - - itemValue, dbFields, err := reflectModelItem(item) - if err != nil { - logger.Printf("failed to reflect model item: %v", err) - return err - } - - exprs := make([]string, 1, len(columns)+1) - exprs[0] = "u_time=?" - args := make([]any, 1, len(columns)+1) - args[0] = time.Now().UTC() - for _, col := range columns { - dbField, ok := dbFields[col] - if !ok { - return fmt.Errorf("column %s does not exist in struct", col) - } - switch col { - case "id", "c_time", "u_time": - return fmt.Errorf("column %s is read-only", col) - } - exprs = append(exprs, fmt.Sprintf("%s=?", col)) - args = append(args, itemValue.FieldByIndex(dbField.Index).Interface()) - } - args = append(args, id) - - query := fmt.Sprintf("UPDATE %s SET %s WHERE id=?", table, strings.Join(exprs, ",")) - result, err := db.ExecContext(ctx, query, args...) - if err != nil { - logger.Printf("db.ExecContext failed: %v", err) - return err - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - logger.Printf("result.RowsAffected failed: %v", err) - return err - } else if rowsAffected == 0 { - return ErrNotExist - } - return nil -} diff --git a/spx-backend/internal/model/query_test.go b/spx-backend/internal/model/query_test.go deleted file mode 100644 index 63cd168c7..000000000 --- a/spx-backend/internal/model/query_test.go +++ /dev/null @@ -1,399 +0,0 @@ -package model - -import ( - "context" - "database/sql" - "testing" - "time" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestQuery(t *testing.T) { - type User struct { - ID int `db:"id"` - Name string `db:"name"` - Status Status `db:"status"` - } - - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC`). - WillReturnRows(mock.NewRows([]string{"id", "name", "status"}). - AddRow(1, "foo", StatusNormal)) - users, err := Query[User](context.Background(), db, "user", nil, nil) - require.NoError(t, err) - require.Len(t, users, 1) - assert.Equal(t, User{ID: 1, Name: "foo", Status: StatusNormal}, users[0]) - }) - - t.Run("ClosedConn", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC`). - WillReturnError(sql.ErrConnDone) - users, err := Query[User](context.Background(), db, "user", nil, nil) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, users) - }) - - t.Run("ScanError", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC`). - WillReturnRows(mock.NewRows([]string{"id", "name", "age", "status"}). - AddRow(1, "foo", 18, StatusNormal)) - users, err := Query[User](context.Background(), db, "user", nil, nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "column age does not exist in struct") - assert.Nil(t, users) - }) -} - -func TestQueryByPage(t *testing.T) { - type User struct { - ID int `db:"id"` - Name string `db:"name"` - Status Status `db:"status"` - } - - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM user WHERE status != \?`). - WillReturnRows(mock.NewRows([]string{"count"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC LIMIT \?, \?`). - WillReturnRows(mock.NewRows([]string{"id", "name", "status"}). - AddRow(1, "foo", StatusNormal)) - paginatedUsers, err := QueryByPage[User](context.Background(), db, "user", Pagination{Index: 1, Size: 10}, nil, nil) - require.NoError(t, err) - assert.Equal(t, 1, paginatedUsers.Total) - require.Len(t, paginatedUsers.Data, 1) - assert.Equal(t, User{ID: 1, Name: "foo", Status: StatusNormal}, paginatedUsers.Data[0]) - }) - - t.Run("ClosedConnForCountQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM user WHERE status != \?`). - WillReturnError(sql.ErrConnDone) - paginatedUsers, err := QueryByPage[User](context.Background(), db, "user", Pagination{Index: 1, Size: 10}, nil, nil) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, paginatedUsers) - }) - - t.Run("ClosedConnForDataQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM user WHERE status != \?`). - WillReturnRows(mock.NewRows([]string{"count"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC LIMIT \?, \?`). - WillReturnError(sql.ErrConnDone) - paginatedUsers, err := QueryByPage[User](context.Background(), db, "user", Pagination{Index: 1, Size: 10}, nil, nil) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, paginatedUsers) - }) - - t.Run("ScanError", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM user WHERE status != \?`). - WillReturnRows(mock.NewRows([]string{"count"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC LIMIT \?, \?`). - WillReturnRows(mock.NewRows([]string{"id", "name", "age", "status"}). - AddRow(1, "foo", 18, StatusNormal)) - paginatedUsers, err := QueryByPage[User](context.Background(), db, "user", Pagination{Index: 1, Size: 10}, nil, nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "column age does not exist in struct") - assert.Nil(t, paginatedUsers) - }) -} - -func TestQueryFirst(t *testing.T) { - type User struct { - ID int `db:"id"` - Name string `db:"name"` - Status Status `db:"status"` - } - - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "status"}). - AddRow(1, "foo", StatusNormal)) - user, err := QueryFirst[User](context.Background(), db, "user", nil, nil) - require.NoError(t, err) - require.NotNil(t, user) - assert.Equal(t, User{ID: 1, Name: "foo", Status: StatusNormal}, *user) - }) - - t.Run("NotExist", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - user, err := QueryFirst[User](context.Background(), db, "user", nil, nil) - require.Error(t, err) - assert.ErrorIs(t, err, ErrNotExist) - assert.Nil(t, user) - }) - - t.Run("ClosedConn", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC LIMIT 1`). - WillReturnError(sql.ErrConnDone) - user, err := QueryFirst[User](context.Background(), db, "user", nil, nil) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, user) - }) - - t.Run("ScanError", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "age", "status"}). - AddRow(1, "foo", 18, StatusNormal)) - user, err := QueryFirst[User](context.Background(), db, "user", nil, nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "column age does not exist in struct") - assert.Nil(t, user) - }) -} - -func TestQueryByID(t *testing.T) { - type User struct { - ID string `db:"id"` - Name string `db:"name"` - Status Status `db:"status"` - } - - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM user WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "status"}). - AddRow(1, "foo", StatusNormal)) - user, err := QueryByID[User](context.Background(), db, "user", "1") - require.NoError(t, err) - require.NotNil(t, user) - assert.Equal(t, User{ID: "1", Name: "foo", Status: StatusNormal}, *user) - }) - - t.Run("NotExist", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM user WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - user, err := QueryByID[User](context.Background(), db, "user", "1") - require.Error(t, err) - assert.ErrorIs(t, err, ErrNotExist) - assert.Nil(t, user) - }) -} - -func TestCreate(t *testing.T) { - type User struct { - ID int `db:"id"` - CTime time.Time `db:"c_time"` - UTime time.Time `db:"u_time"` - Name string `db:"name"` - Status Status `db:"status"` - } - - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`INSERT INTO user \(.+\) VALUES \(\?,\?,\?,\?\)`). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT \* FROM user WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "status"}). - AddRow(1, "foo", StatusNormal)) - user, err := Create(context.Background(), db, "user", &User{Name: "foo", Status: StatusNormal}) - require.NoError(t, err) - require.NotNil(t, user) - assert.Equal(t, User{ID: 1, Name: "foo", Status: StatusNormal}, *user) - }) - - t.Run("InvalidType", func(t *testing.T) { - db, _, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - var user int - _, err = Create(context.Background(), db, "user", &user) - require.Error(t, err) - assert.EqualError(t, err, "item must be a struct") - }) - - t.Run("NoDBFields", func(t *testing.T) { - db, _, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - var user struct{} - _, err = Create(context.Background(), db, "user", &user) - require.Error(t, err) - assert.EqualError(t, err, "no db fields found") - }) - - t.Run("ClosedConnForInsertQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`INSERT INTO user \(.+\) VALUES \(\?,\?,\?,\?\)`). - WillReturnError(sql.ErrConnDone) - _, err = Create(context.Background(), db, "user", &User{Name: "foo", Status: StatusNormal}) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) - - t.Run("ClosedConnForLastInsertID", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`INSERT INTO user \(.+\) VALUES \(\?,\?,\?,\?\)`). - WillReturnResult(sqlmock.NewErrorResult(sql.ErrConnDone)) - _, err = Create(context.Background(), db, "user", &User{Name: "foo", Status: StatusNormal}) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} - -func TestUpdateByID(t *testing.T) { - type User struct { - ID string `db:"id"` - CTime time.Time `db:"c_time"` - UTime time.Time `db:"u_time"` - Name string `db:"name"` - Status Status `db:"status"` - } - - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE user SET u_time=\?,name=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), "foo", "1"). - WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectQuery(`SELECT \* FROM user WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "status"}). - AddRow("1", "foo", StatusNormal)) - err = UpdateByID(context.Background(), db, "user", "1", &User{Name: "foo"}, "name") - require.NoError(t, err) - }) - - t.Run("InvalidType", func(t *testing.T) { - db, _, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - var user int - err = UpdateByID(context.Background(), db, "user", "1", &user, "name") - require.Error(t, err) - assert.EqualError(t, err, "item must be a struct") - }) - - t.Run("ColumnNotExist", func(t *testing.T) { - db, _, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - var user struct{} - err = UpdateByID(context.Background(), db, "user", "1", &user, "name") - require.Error(t, err) - assert.EqualError(t, err, "column name does not exist in struct") - }) - - t.Run("ReadOnlyColumn", func(t *testing.T) { - db, _, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - var user User - err = UpdateByID(context.Background(), db, "user", "1", &user, "id") - require.Error(t, err) - assert.EqualError(t, err, "column id is read-only") - }) - - t.Run("NotExist", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE user SET u_time=\?,name=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), "foo", "1"). - WillReturnResult(sqlmock.NewResult(0, 0)) - err = UpdateByID(context.Background(), db, "user", "1", &User{Name: "foo"}, "name") - require.Error(t, err) - assert.ErrorIs(t, err, ErrNotExist) - }) - - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE user SET u_time=\?,name=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), "foo", "1"). - WillReturnError(sql.ErrConnDone) - err = UpdateByID(context.Background(), db, "user", "1", &User{Name: "foo"}, "name") - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) - - t.Run("ClosedConnForRowsAffected", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE user SET u_time=\?,name=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), "foo", "1"). - WillReturnResult(sqlmock.NewErrorResult(sql.ErrConnDone)) - err = UpdateByID(context.Background(), db, "user", "1", &User{Name: "foo"}, "name") - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} diff --git a/spx-backend/internal/model/scan.go b/spx-backend/internal/model/scan.go deleted file mode 100644 index 83f03c9ad..000000000 --- a/spx-backend/internal/model/scan.go +++ /dev/null @@ -1,73 +0,0 @@ -package model - -import ( - "database/sql" - "errors" - "fmt" - "reflect" -) - -// dbFieldsForRegisteredModels is a map of registered models to their database fields. -var dbFieldsForRegisteredModels = map[reflect.Type]map[string]reflect.StructField{ - reflect.TypeOf(Project{}): reflectModelDBFields(reflect.TypeOf(Project{})), - reflect.TypeOf(Asset{}): reflectModelDBFields(reflect.TypeOf(Asset{})), -} - -// reflectModelDBFields returns a map of database columns to struct fields based -// on the "db" tag. -// -// The "db" tag specifies the column name in the database and is required for -// each field that should be scanned. Fields without the "db" tag or unexported -// fields are ignored. -func reflectModelDBFields(t reflect.Type) map[string]reflect.StructField { - fields := reflect.VisibleFields(t) - m := make(map[string]reflect.StructField, len(fields)) - for _, field := range fields { - if !field.IsExported() { - continue - } - dbTag, ok := field.Tag.Lookup("db") - if !ok { - continue - } - m[dbTag] = field - } - return m -} - -// reflectModelItem returns the [reflect.Value] and database fields for a model item. -func reflectModelItem(item any) (itemValue reflect.Value, dbFields map[string]reflect.StructField, err error) { - itemValue = reflect.ValueOf(item).Elem() - itemType := itemValue.Type() - if itemType.Kind() != reflect.Struct { - return reflect.Value{}, nil, errors.New("item must be a struct") - } - dbFields, ok := dbFieldsForRegisteredModels[itemType] - if !ok { - dbFields = reflectModelDBFields(itemType) - } - return -} - -// rowsScan scans a single SQL row into a generic struct with automatic field mapping. -func rowsScan[T any](rows *sql.Rows) (T, error) { - var item T - itemValue, dbFields, err := reflectModelItem(&item) - if err != nil { - return item, err - } - - columns, err := rows.Columns() - if err != nil { - return item, err - } - dests := make([]any, len(columns)) - for i, col := range columns { - dbField, ok := dbFields[col] - if !ok { - return item, fmt.Errorf("column %s does not exist in struct", col) - } - dests[i] = itemValue.FieldByName(dbField.Name).Addr().Interface() - } - return item, rows.Scan(dests...) -} diff --git a/spx-backend/internal/model/scan_test.go b/spx-backend/internal/model/scan_test.go deleted file mode 100644 index e982273f0..000000000 --- a/spx-backend/internal/model/scan_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package model - -import ( - "reflect" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestReflectModelDBFields(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - type User struct { - ID int `db:"id"` - Name string `db:"name"` - } - fields := reflectModelDBFields(reflect.TypeOf(User{})) - require.NotNil(t, fields) - assert.Contains(t, fields, "id") - assert.Contains(t, fields, "name") - }) - - t.Run("UnexportedField", func(t *testing.T) { - type User struct { - ID int `db:"id"` - name string `db:"name"` - } - fields := reflectModelDBFields(reflect.TypeOf(User{})) - require.NotNil(t, fields) - assert.Contains(t, fields, "id") - assert.NotContains(t, fields, "name") - }) - - t.Run("NoDBTag", func(t *testing.T) { - type User struct { - ID int - Name string - } - fields := reflectModelDBFields(reflect.TypeOf(User{})) - require.NotNil(t, fields) - assert.Empty(t, fields) - }) -} - -func TestReflectModelItem(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - type User struct { - ID int `db:"id"` - Name string `db:"name"` - } - user := User{} - value, fields, err := reflectModelItem(&user) - require.NoError(t, err) - assert.NotZero(t, value) - assert.NotEmpty(t, fields) - }) - - t.Run("InvalidType", func(t *testing.T) { - var user int - value, fields, err := reflectModelItem(&user) - require.Error(t, err) - assert.EqualError(t, err, "item must be a struct") - assert.Zero(t, value) - assert.Empty(t, fields) - }) -} - -func TestRowsScan(t *testing.T) { - type User struct { - ID int `db:"id"` - Name string `db:"name"` - } - - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - var users []User - mock.ExpectQuery("SELECT id, name FROM user"). - WillReturnRows(mock.NewRows([]string{"id", "name"}). - AddRow(1, "foo"). - AddRow(2, "bar")) - rows, err := db.Query("SELECT id, name FROM user") - require.NoError(t, err) - defer rows.Close() - for rows.Next() { - user, err := rowsScan[User](rows) - require.NoError(t, err) - assert.NotZero(t, user) - users = append(users, user) - } - require.Len(t, users, 2) - assert.Equal(t, User{ID: 1, Name: "foo"}, users[0]) - assert.Equal(t, User{ID: 2, Name: "bar"}, users[1]) - }) - - t.Run("InvalidType", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery("SELECT id, name FROM user"). - WillReturnRows(mock.NewRows([]string{"id", "name"}). - AddRow(1, "foo")) - rows, err := db.Query("SELECT id, name FROM user") - require.NoError(t, err) - defer rows.Close() - for rows.Next() { - user, err := rowsScan[int](rows) - require.Error(t, err) - assert.EqualError(t, err, "item must be a struct") - assert.Zero(t, user) - } - }) - - t.Run("NULL", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery("SELECT id, name FROM user"). - WillReturnRows(mock.NewRows([]string{"id", "name"}). - AddRow(1, nil)) - rows, err := db.Query("SELECT id, name FROM user") - require.NoError(t, err) - defer rows.Close() - for rows.Next() { - _, err := rowsScan[User](rows) - require.Error(t, err) - assert.Contains(t, err.Error(), "converting NULL to string is unsupported") - } - }) - - t.Run("ColumnNotExist", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery("SELECT id, name, age FROM user"). - WillReturnRows(mock.NewRows([]string{"id", "name", "age"}). - AddRow(1, "foo", 18)) - rows, err := db.Query("SELECT id, name, age FROM user") - require.NoError(t, err) - defer rows.Close() - for rows.Next() { - user, err := rowsScan[User](rows) - require.Error(t, err) - assert.EqualError(t, err, "column age does not exist in struct") - assert.Zero(t, user) - } - }) - - t.Run("ClosedRows", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery("SELECT id, name FROM user"). - WillReturnRows(mock.NewRows([]string{"id", "name"}). - AddRow(1, "foo")) - rows, err := db.Query("SELECT id, name FROM user") - require.NoError(t, err) - defer rows.Close() - for rows.Next() { - require.NoError(t, rows.Close()) - user, err := rowsScan[User](rows) - require.Error(t, err) - assert.EqualError(t, err, "sql: Rows are closed") - assert.Zero(t, user) - } - }) -} diff --git a/spx-backend/internal/model/user.go b/spx-backend/internal/model/user.go new file mode 100644 index 000000000..c09cef2c0 --- /dev/null +++ b/spx-backend/internal/model/user.go @@ -0,0 +1,66 @@ +package model + +import ( + "context" + "fmt" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// User is the model for users. +type User struct { + Model + + // Username is the unique username. + Username string `gorm:"column:username;index:,unique,where:deleted_at IS NULL"` + + // Description is the brief bio or description. + Description string `gorm:"column:description"` + + // FollowerCount is the number of followers the user has. + FollowerCount int64 `gorm:"column:follower_count"` + + // FollowingCount is the number of users the user is following. + FollowingCount int64 `gorm:"column:following_count"` + + // ProjectCount is the total number of projects created by the user. + ProjectCount int64 `gorm:"column:project_count"` + + // PublicProjectCount is the number of public projects created by the user. + PublicProjectCount int64 `gorm:"column:public_project_count"` + + // LikedProjectCount is the number of projects liked by the user. + LikedProjectCount int64 `gorm:"column:liked_project_count"` +} + +// TableName implements [gorm.io/gorm/schema.Tabler]. +func (User) TableName() string { + return "user" +} + +// FirstOrCreateUser gets or creates a user. +func FirstOrCreateUser(ctx context.Context, db *gorm.DB, username string) (*User, error) { + var mUser User + if err := db.WithContext(ctx). + Where("username = ?", username). + Attrs(User{Username: username}). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "username"}}, + DoNothing: true, + }). + FirstOrCreate(&mUser). + Error; err != nil { + return nil, fmt.Errorf("failed to get/create user %s: %w", username, err) + } + if mUser.ID == 0 { + // Unfortunatlly, MySQL doesn't support the RETURNING clause. + if err := db.WithContext(ctx). + Where("username = ?", username). + First(&mUser). + Error; err != nil { + return nil, fmt.Errorf("failed to get user %s: %w", username, err) + } + } + return &mUser, nil +} diff --git a/spx-backend/internal/model/user_project_relationship.go b/spx-backend/internal/model/user_project_relationship.go new file mode 100644 index 000000000..d114500c8 --- /dev/null +++ b/spx-backend/internal/model/user_project_relationship.go @@ -0,0 +1,69 @@ +package model + +import ( + "context" + "database/sql" + "fmt" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// UserProjectRelationship is the model for user-project relationships. +type UserProjectRelationship struct { + Model + + // UserID is the ID of the user involved in the relationship. + UserID int64 `gorm:"column:user_id;index;index:,composite:user_id_project_id,unique"` + User User `gorm:"foreignKey:UserID"` + + // ProjectID is the ID of the project involved in the relationship. + ProjectID int64 `gorm:"column:project_id;index;index:,composite:user_id_project_id,unique"` + Project Project `gorm:"foreignKey:ProjectID"` + + // ViewCount is the number of times the user has viewed the project. + ViewCount int64 `gorm:"column:view_count;index"` + + // LastViewedAt is the time when the user last viewed the project. + // + // If LastViewedAt.Valid is false, it means the user has not viewed the project. + LastViewedAt sql.NullTime `gorm:"column:last_viewed_at;index"` + + // LikedAt is the time when the user liked the project. + // + // If LikedAt.Valid is false, it means the user has not liked the project. + LikedAt sql.NullTime `gorm:"column:liked_at;index"` +} + +// TableName implements [gorm.io/gorm/schema.Tabler]. +func (UserProjectRelationship) TableName() string { + return "user_project_relationship" +} + +// FirstOrCreateUserProjectRelationship gets or creates a user-project relationship. +func FirstOrCreateUserProjectRelationship(ctx context.Context, db *gorm.DB, userID, projectID int64) (*UserProjectRelationship, error) { + var mUserProjectRelationship UserProjectRelationship + if err := db.WithContext(ctx). + Where("user_id = ?", userID). + Where("project_id = ?", projectID). + Attrs(UserProjectRelationship{UserID: userID, ProjectID: projectID}). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "project_id"}}, + DoNothing: true, + }). + FirstOrCreate(&mUserProjectRelationship). + Error; err != nil { + return nil, fmt.Errorf("failed to get/create user-project relationship: %w", err) + } + if mUserProjectRelationship.ID == 0 { + // Unfortunatlly, MySQL doesn't support the RETURNING clause. + if err := db.WithContext(ctx). + Where("user_id = ?", userID). + Where("project_id = ?", projectID). + First(&mUserProjectRelationship). + Error; err != nil { + return nil, fmt.Errorf("failed to get user-project relationship: %w", err) + } + } + return &mUserProjectRelationship, nil +} diff --git a/spx-backend/internal/model/user_project_relationship_test.go b/spx-backend/internal/model/user_project_relationship_test.go new file mode 100644 index 000000000..b89f63d42 --- /dev/null +++ b/spx-backend/internal/model/user_project_relationship_test.go @@ -0,0 +1,106 @@ +package model + +import ( + "context" + "database/sql" + "database/sql/driver" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/goplus/builder/spx-backend/internal/model/modeltest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func TestFirstOrCreateUserProjectRelationship(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userProjectRelationshipDBColumns, err := modeltest.ExtractDBColumns(db, UserProjectRelationship{}) + require.NoError(t, err) + generateUserProjectRelationshipDBRows, err := modeltest.NewDBRowsGenerator(db, UserProjectRelationship{}) + require.NoError(t, err) + + mExpectedUserProjectRelationship := UserProjectRelationship{ + Model: Model{ID: 1}, + UserID: 1, + ProjectID: 2, + ViewCount: 10, + LastViewedAt: sql.NullTime{Valid: true}, + LikedAt: sql.NullTime{Valid: true}, + } + + t.Run("Normal", func(t *testing.T) { + db, dbMock, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + defer closeDB() + + dbMockStmt := db.Session(&gorm.Session{DryRun: true}). + Where("user_id = ?", mExpectedUserProjectRelationship.UserID). + Where("project_id = ?", mExpectedUserProjectRelationship.ProjectID). + First(&UserProjectRelationship{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userProjectRelationshipDBColumns).AddRows(generateUserProjectRelationshipDBRows(mExpectedUserProjectRelationship)...)) + + mUserProjectRelationship, err := FirstOrCreateUserProjectRelationship(context.Background(), db, mExpectedUserProjectRelationship.UserID, mExpectedUserProjectRelationship.ProjectID) + require.NoError(t, err) + assert.Equal(t, mExpectedUserProjectRelationship, *mUserProjectRelationship) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("NotExist", func(t *testing.T) { + db, dbMock, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + defer closeDB() + + dbMockStmt := db.Session(&gorm.Session{DryRun: true}). + Where("user_id = ?", mExpectedUserProjectRelationship.UserID). + Where("project_id = ?", mExpectedUserProjectRelationship.ProjectID). + First(&UserProjectRelationship{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userProjectRelationshipDBColumns)) + + dbMock.ExpectBegin() + dbMockStmt = db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "project_id"}}, + DoNothing: true, + }). + Create(&UserProjectRelationship{UserID: mExpectedUserProjectRelationship.UserID, ProjectID: mExpectedUserProjectRelationship.ProjectID}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[0] = sqlmock.AnyArg() + dbMockArgs[1] = sqlmock.AnyArg() + dbMockArgs[2] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(driver.ResultNoRows) + dbMock.ExpectCommit() + + dbMockStmt = db.Session(&gorm.Session{DryRun: true}). + Where("user_id = ?", mExpectedUserProjectRelationship.UserID). + Where("project_id = ?", mExpectedUserProjectRelationship.ProjectID). + First(&UserProjectRelationship{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userProjectRelationshipDBColumns).AddRows(generateUserProjectRelationshipDBRows(mExpectedUserProjectRelationship)...)) + + mUserProjectRelationship, err := FirstOrCreateUserProjectRelationship(context.Background(), db, mExpectedUserProjectRelationship.UserID, mExpectedUserProjectRelationship.ProjectID) + require.NoError(t, err) + assert.Equal(t, mExpectedUserProjectRelationship, *mUserProjectRelationship) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) +} diff --git a/spx-backend/internal/model/user_relationship.go b/spx-backend/internal/model/user_relationship.go new file mode 100644 index 000000000..e8299a0bd --- /dev/null +++ b/spx-backend/internal/model/user_relationship.go @@ -0,0 +1,62 @@ +package model + +import ( + "context" + "database/sql" + "fmt" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// UserRelationship is the model for user relationships. +type UserRelationship struct { + Model + + // UserID is the ID of the user involved in the relationship. + UserID int64 `gorm:"column:user_id;index;index:,composite:user_id_target_user_id,unique"` + User User `gorm:"foreignKey:UserID"` + + // TargetUserID is the ID of the target user involved in the relationship. + TargetUserID int64 `gorm:"column:target_user_id;index;index:,composite:user_id_target_user_id,unique"` + TargetUser User `gorm:"foreignKey:TargetUserID"` + + // FollowedAt is the time when the user followed the target user. + // + // If FollowedAt.Valid is false, it means the user is not following the + // target user. + FollowedAt sql.NullTime `gorm:"column:followed_at;index"` +} + +// TableName implements [gorm.io/gorm/schema.Tabler]. +func (UserRelationship) TableName() string { + return "user_relationship" +} + +// FirstOrCreateUserRelationship gets or creates a user relationship. +func FirstOrCreateUserRelationship(ctx context.Context, db *gorm.DB, userID, targetUserID int64) (*UserRelationship, error) { + var mUserRelationship UserRelationship + if err := db.WithContext(ctx). + Where("user_id = ?", userID). + Where("target_user_id = ?", targetUserID). + Attrs(UserRelationship{UserID: userID, TargetUserID: targetUserID}). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "target_user_id"}}, + DoNothing: true, + }). + FirstOrCreate(&mUserRelationship). + Error; err != nil { + return nil, fmt.Errorf("failed to get/create user relationship: %w", err) + } + if mUserRelationship.ID == 0 { + // Unfortunatlly, MySQL doesn't support the RETURNING clause. + if err := db.WithContext(ctx). + Where("user_id = ?", userID). + Where("target_user_id = ?", targetUserID). + First(&mUserRelationship). + Error; err != nil { + return nil, fmt.Errorf("failed to get user relationship: %w", err) + } + } + return &mUserRelationship, nil +} diff --git a/spx-backend/internal/model/user_relationship_test.go b/spx-backend/internal/model/user_relationship_test.go new file mode 100644 index 000000000..8f7c5fb8e --- /dev/null +++ b/spx-backend/internal/model/user_relationship_test.go @@ -0,0 +1,104 @@ +package model + +import ( + "context" + "database/sql" + "database/sql/driver" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/goplus/builder/spx-backend/internal/model/modeltest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func TestFirstOrCreateUserRelationship(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userRelationshipDBColumns, err := modeltest.ExtractDBColumns(db, UserRelationship{}) + require.NoError(t, err) + generateUserRelationshipDBRows, err := modeltest.NewDBRowsGenerator(db, UserRelationship{}) + require.NoError(t, err) + + mExpectedUserRelationship := UserRelationship{ + Model: Model{ID: 1}, + UserID: 1, + TargetUserID: 2, + FollowedAt: sql.NullTime{Valid: true}, + } + + t.Run("Normal", func(t *testing.T) { + db, dbMock, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + defer closeDB() + + dbMockStmt := db.Session(&gorm.Session{DryRun: true}). + Where("user_id = ?", mExpectedUserRelationship.UserID). + Where("target_user_id = ?", mExpectedUserRelationship.TargetUserID). + First(&UserRelationship{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userRelationshipDBColumns).AddRows(generateUserRelationshipDBRows(mExpectedUserRelationship)...)) + + mUserRelationship, err := FirstOrCreateUserRelationship(context.Background(), db, mExpectedUserRelationship.UserID, mExpectedUserRelationship.TargetUserID) + require.NoError(t, err) + assert.Equal(t, mExpectedUserRelationship, *mUserRelationship) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("NotExist", func(t *testing.T) { + db, dbMock, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + defer closeDB() + + dbMockStmt := db.Session(&gorm.Session{DryRun: true}). + Where("user_id = ?", mExpectedUserRelationship.UserID). + Where("target_user_id = ?", mExpectedUserRelationship.TargetUserID). + First(&UserRelationship{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userRelationshipDBColumns)) + + dbMock.ExpectBegin() + dbMockStmt = db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "target_user_id"}}, + DoNothing: true, + }). + Create(&UserRelationship{UserID: mExpectedUserRelationship.UserID, TargetUserID: mExpectedUserRelationship.TargetUserID}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[0] = sqlmock.AnyArg() + dbMockArgs[1] = sqlmock.AnyArg() + dbMockArgs[2] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(driver.ResultNoRows) + dbMock.ExpectCommit() + + dbMockStmt = db.Session(&gorm.Session{DryRun: true}). + Where("user_id = ?", mExpectedUserRelationship.UserID). + Where("target_user_id = ?", mExpectedUserRelationship.TargetUserID). + First(&UserRelationship{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userRelationshipDBColumns).AddRows(generateUserRelationshipDBRows(mExpectedUserRelationship)...)) + + mUserRelationship, err := FirstOrCreateUserRelationship(context.Background(), db, mExpectedUserRelationship.UserID, mExpectedUserRelationship.TargetUserID) + require.NoError(t, err) + assert.Equal(t, mExpectedUserRelationship, *mUserRelationship) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) +} diff --git a/spx-backend/internal/model/user_test.go b/spx-backend/internal/model/user_test.go new file mode 100644 index 000000000..9d9864321 --- /dev/null +++ b/spx-backend/internal/model/user_test.go @@ -0,0 +1,104 @@ +package model + +import ( + "context" + "database/sql/driver" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/goplus/builder/spx-backend/internal/model/modeltest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func TestFirstOrCreateUser(t *testing.T) { + db, _, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + closeDB() + userDBColumns, err := modeltest.ExtractDBColumns(db, User{}) + require.NoError(t, err) + generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, User{}) + require.NoError(t, err) + + mExpectedUser := User{ + Model: Model{ID: 1}, + Username: "john", + Description: "I'm John", + FollowerCount: 10, + FollowingCount: 5, + ProjectCount: 3, + PublicProjectCount: 2, + LikedProjectCount: 15, + } + + t.Run("Normal", func(t *testing.T) { + db, dbMock, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + defer closeDB() + + dbMockStmt := db.Session(&gorm.Session{DryRun: true}). + Where("username = ?", mExpectedUser.Username). + First(&User{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(mExpectedUser)...)) + + mUser, err := FirstOrCreateUser(context.Background(), db, mExpectedUser.Username) + require.NoError(t, err) + assert.Equal(t, mExpectedUser, *mUser) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) + + t.Run("NotExist", func(t *testing.T) { + db, dbMock, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + defer closeDB() + + dbMockStmt := db.Session(&gorm.Session{DryRun: true}). + Where("username = ?", mExpectedUser.Username). + First(&User{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns)) + + dbMock.ExpectBegin() + dbMockStmt = db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "username"}}, + DoNothing: true, + }). + Create(&User{Username: mExpectedUser.Username}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[0] = sqlmock.AnyArg() + dbMockArgs[1] = sqlmock.AnyArg() + dbMockArgs[2] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(driver.ResultNoRows) + dbMock.ExpectCommit() + + dbMockStmt = db.Session(&gorm.Session{DryRun: true}). + Where("username = ?", mExpectedUser.Username). + First(&User{}). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(mExpectedUser)...)) + + mUser, err := FirstOrCreateUser(context.Background(), db, mExpectedUser.Username) + require.NoError(t, err) + assert.Equal(t, mExpectedUser, *mUser) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) +} diff --git a/spx-gui/src/apis/asset.ts b/spx-gui/src/apis/asset.ts index ab59facff..4366af5e3 100644 --- a/spx-gui/src/apis/asset.ts +++ b/spx-gui/src/apis/asset.ts @@ -1,40 +1,36 @@ import type { FileCollection, ByPage, PaginationParams } from './common' -import { client, IsPublic } from './common' +import { client, Visibility } from './common' -export { IsPublic } +export { Visibility } export enum AssetType { - Sprite = 0, - Backdrop = 1, - Sound = 2 + Sprite = 'sprite', + Backdrop = 'backdrop', + Sound = 'sound' } export type AssetData = { - /** Globally unique ID */ + /** Unique identifier */ id: string - /** Name to display */ - displayName: string - /** Name of asset owner */ + /** Username of the asset's owner */ owner: string - /** Asset Category */ + /** Display name of the asset */ + displayName: string + /** Type of the asset */ + type: AssetType + /** Category to which the asset belongs */ category: string - /** Asset Type */ - assetType: AssetType - /** Files the asset contains */ + /** File paths and their corresponding universal URLs associated with the asset */ files: FileCollection - /** Hash of the files */ + /** Hash of the asset files */ filesHash: string - /** Preview URL for the asset, e.g., a gif for a sprite */ - preview: string - /** Click count of the asset */ - clickCount: number - /** Public status */ - isPublic: IsPublic + /** Visibility of the asset */ + visibility: Visibility } export type AddAssetParams = Pick< AssetData, - 'displayName' | 'category' | 'assetType' | 'files' | 'filesHash' | 'preview' | 'isPublic' + 'displayName' | 'type' | 'category' | 'files' | 'filesHash' | 'visibility' > export function addAsset(params: AddAssetParams) { @@ -51,20 +47,15 @@ export function deleteAsset(id: string) { return client.delete(`/asset/${encodeURIComponent(id)}`) as Promise } -export enum ListAssetParamOrderBy { - Default = 'default', - TimeDesc = 'time', - ClickCountDesc = 'clickCount' -} - export type ListAssetParams = PaginationParams & { keyword?: string owner?: string + type?: AssetType category?: string - assetType?: AssetType filesHash?: string - isPublic?: IsPublic - orderBy?: ListAssetParamOrderBy + visibility?: Visibility + orderBy?: 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' } export function listAsset(params?: ListAssetParams) { diff --git a/spx-gui/src/apis/common/index.ts b/spx-gui/src/apis/common/index.ts index 55b684f16..78b21fc46 100644 --- a/spx-gui/src/apis/common/index.ts +++ b/spx-gui/src/apis/common/index.ts @@ -12,9 +12,9 @@ export type ByPage = { export const ownerAll = '*' -export enum IsPublic { - personal = 0, - public = 1 +export enum Visibility { + Private = 'private', + Public = 'public' } /** Url with 'http:', 'https:', or 'data:' schemes, used for web resources that can be accessed directly via `fetch()` */ diff --git a/spx-gui/src/apis/project.ts b/spx-gui/src/apis/project.ts index 75e5b94ce..10a74bc2a 100644 --- a/spx-gui/src/apis/project.ts +++ b/spx-gui/src/apis/project.ts @@ -1,9 +1,9 @@ import dayjs from 'dayjs' import type { FileCollection, ByPage, PaginationParams } from './common' -import { client, IsPublic, ownerAll, timeStringify } from './common' +import { client, Visibility, ownerAll, timeStringify } from './common' import { ApiException, ApiExceptionCode } from './common/exception' -export { IsPublic, ownerAll } +export { Visibility, ownerAll } export enum ProjectDataType { Sprite = 0, @@ -12,20 +12,24 @@ export enum ProjectDataType { } export type ProjectData = { - /** Globally Unique ID */ + /** Unique identifier */ id: string - /** Project name, unique for projects of same owner */ - name: string - /** Name of project owner */ + /** Creation timestamp */ + createdAt: string + /** Last update timestamp */ + updatedAt: string + /** Unique username of the user */ owner: string - /** Public status */ - isPublic: IsPublic - /** Files the project contains */ - files: FileCollection - /** Project version */ - version: number /** Full name of the project release from which the project is remixed */ remixedFrom: string + /** Unique name of the project */ + name: string + /** Version number of the project */ + version: number + /** File paths and their corresponding universal URLs associated with the project */ + files: FileCollection + /** Visibility of the project */ + visibility: Visibility /** Brief description of the project */ description: string /** Instructions on how to interact with the project */ @@ -40,10 +44,6 @@ export type ProjectData = { releaseCount: number /** Number of remixes associated with the project */ remixCount: number - /** Create time */ - cTime: string - /** Update time */ - uTime: string } // TODO: remove me @@ -75,13 +75,13 @@ function __adaptProjectData(p: unknown): ProjectData { } } -export type AddProjectParams = Pick +export type AddProjectParams = Pick export async function addProject(params: AddProjectParams, signal?: AbortSignal) { return __adaptProjectData(await client.post('/project', params, { signal })) } -export type UpdateProjectParams = Pick +export type UpdateProjectParams = Pick function encode(owner: string, name: string) { return `${encodeURIComponent(owner)}/${encodeURIComponent(name)}` @@ -101,11 +101,15 @@ export function deleteProject(owner: string, name: string) { } export type ListProjectParams = PaginationParams & { - isPublic?: IsPublic - /** Name of project owner, `*` indicates projects of all users */ + /** + * Filter projects by the owner's username. + * Defaults to the authenticated user if not specified. Use * to include projects from all users. + **/ owner?: string /** Filter projects by name pattern */ keyword?: string + /** Filter projects by visibility */ + visibility?: Visibility /** Filter projects liked by the specified user */ liker?: string /** Filter projects that were created after this timestamp */ @@ -117,7 +121,13 @@ export type ListProjectParams = PaginationParams & { /** If filter projects created by followees of logged-in user */ fromFollowees?: boolean /** Field by which to order the results */ - orderBy?: 'cTime' | 'uTime' | 'likeCount' | 'remixCount' | 'recentLikeCount' | 'recentRemixCount' + orderBy?: + | 'createdAt' + | 'updatedAt' + | 'likeCount' + | 'remixCount' + | 'recentLikeCount' + | 'recentRemixCount' /** Order in which to sort the results */ sortOrder?: 'asc' | 'desc' } @@ -156,7 +166,7 @@ export async function exploreProjects({ order, count }: ExploreParams) { // count within the last month const countAfter = timeStringify(dayjs().subtract(1, 'month').valueOf()) const p: ListProjectParams = { - isPublic: IsPublic.public, + visibility: Visibility.Public, owner: ownerAll, pageSize: count, pageIndex: 1 @@ -175,7 +185,7 @@ export async function exploreProjects({ order, count }: ExploreParams) { case ExploreOrder.FollowingCreated: p.fromFollowees = true p.createdAfter = countAfter - p.orderBy = 'cTime' + p.orderBy = 'createdAt' p.sortOrder = 'desc' break } @@ -191,7 +201,7 @@ export async function isLiking(owner: string, name: string) { // TODO: remove me if (process.env.NODE_ENV === 'development') return Math.random() > 0.5 try { - await client.get(`/project/liking/${encode(owner, name)}`) + await client.get(`/project/${encode(owner, name)}/liking`) return true } catch (e) { if (e instanceof ApiException) { diff --git a/spx-gui/src/apis/user.ts b/spx-gui/src/apis/user.ts index e4a67e8e1..58d3b4f7c 100644 --- a/spx-gui/src/apis/user.ts +++ b/spx-gui/src/apis/user.ts @@ -4,6 +4,10 @@ import { ApiException, ApiExceptionCode } from './common/exception' export type User = { /** Unique identifier */ id: string + /** Creation timestamp */ + createdAt: string + /** Last update timestamp */ + updatedAt: string /** Unique username of the user */ username: string /** Brief bio or description of the user */ @@ -12,10 +16,6 @@ export type User = { displayName: string /** Avatar URL, TODO: from Casdoor? */ avatar: string - /** Create time */ - cTime: string - /** Update time */ - uTime: string } function __mockUser(name: string): User { @@ -26,8 +26,8 @@ function __mockUser(name: string): User { 'All the world’s a stage, and all the men and women merely players. They have their exits and their entrances; and one man in his time plays many parts.', displayName: name, avatar: 'https://avatars.githubusercontent.com/u/1492263?v=4', - cTime: '2021-08-07T07:00:00Z', - uTime: '2021-08-07T07:00:00Z' + createdAt: '2021-08-07T07:00:00Z', + updatedAt: '2021-08-07T07:00:00Z' } } @@ -50,7 +50,7 @@ export type ListUserParams = PaginationParams & { /** Filter users who are following the specified user */ followee?: string /** Field by which to order the results */ - orderBy?: 'cTime' | 'uTime' | 'followedAt' + orderBy?: 'createdAt' | 'updatedAt' | 'followedAt' /** Order in which to sort the results */ sortOrder?: 'asc' | 'desc' } @@ -79,7 +79,7 @@ export async function isFollowing(username: string) { return Math.random() > 0.5 } try { - await client.get(`/user/following/${encodeURIComponent(username)}`) + await client.get(`/user/${encodeURIComponent(username)}/following`) return true } catch (e) { if (e instanceof ApiException) { @@ -100,7 +100,7 @@ export async function follow(username: string) { if (Math.random() > 0.5) throw new Error('Failed to follow') return } - await client.post(`/user/following/${encodeURIComponent(username)}`) + await client.post(`/user/${encodeURIComponent(username)}/following`) } export async function unfollow(username: string) { @@ -110,5 +110,5 @@ export async function unfollow(username: string) { if (Math.random() > 0.5) throw new Error('Failed to follow') return } - await client.delete(`/user/following/${encodeURIComponent(username)}`) + await client.delete(`/user/${encodeURIComponent(username)}/following`) } diff --git a/spx-gui/src/components/asset/library/AssetAddModal.vue b/spx-gui/src/components/asset/library/AssetAddModal.vue index dcd2a9001..65f24b49c 100644 --- a/spx-gui/src/components/asset/library/AssetAddModal.vue +++ b/spx-gui/src/components/asset/library/AssetAddModal.vue @@ -33,9 +33,9 @@ - + @@ -61,14 +61,7 @@ import { useForm, useConfirmDialog } from '@/components/ui' -import { - type AssetData, - addAsset, - listAsset, - IsPublic, - ListAssetParamOrderBy, - AssetType -} from '@/apis/asset' +import { type AssetData, addAsset, listAsset, Visibility, AssetType } from '@/apis/asset' import { useMessageHandle } from '@/utils/exception' import { Backdrop } from '@/models/backdrop' import { Sound } from '@/models/sound' @@ -100,7 +93,7 @@ const { t } = useI18n() const form = useForm({ name: [props.asset.name, validateName], category: [categoryAll.value], - isPublic: [false] + visibilityPublic: [false] }) const withConfirm = useConfirmDialog() @@ -126,13 +119,14 @@ const handleSubmit = useMessageHandle( const { data: assets } = await listAsset({ pageSize: 1, // we only need to know if the asset with the same filesHash exists pageIndex: 1, - assetType: params.assetType, + type: params.type, filesHash: params.filesHash, - orderBy: ListAssetParamOrderBy.TimeDesc + orderBy: 'createdAt', + sortOrder: 'desc' }) if (assets.length) { let assetTypeName = t({ en: 'asset', zh: '素材' }) - switch (params.assetType) { + switch (params.type) { case AssetType.Sprite: assetTypeName = t({ en: 'sprite', zh: '精灵' }) break @@ -160,8 +154,7 @@ const handleSubmit = useMessageHandle( ...params, displayName: form.value.name, category: form.value.category, - preview: 'TODO', - isPublic: form.value.isPublic ? IsPublic.public : IsPublic.personal + visibility: form.value.visibilityPublic ? Visibility.Public : Visibility.Private }) emit('resolved') return assetData diff --git a/spx-gui/src/components/asset/library/AssetLibraryModal.vue b/spx-gui/src/components/asset/library/AssetLibraryModal.vue index 097d5a50b..5aa08c386 100644 --- a/spx-gui/src/components/asset/library/AssetLibraryModal.vue +++ b/spx-gui/src/components/asset/library/AssetLibraryModal.vue @@ -106,7 +106,7 @@ import { UISearchableModal, UIDivider } from '@/components/ui' -import { listAsset, AssetType, type AssetData, IsPublic } from '@/apis/asset' +import { listAsset, AssetType, type AssetData, Visibility } from '@/apis/asset' import { debounce } from 'lodash' import { useMessageHandle, useQuery } from '@/utils/exception' import { type Category, categories as categoriesWithoutAll, categoryAll } from './category' @@ -165,13 +165,13 @@ const { const c = category.value.value const cPersonal = categoryPersonal.value.value return listAsset({ - pageSize: 500, // try to get all + pageSize: 100, // try to get all pageIndex: 1, - assetType: props.type, + type: props.type, keyword: keyword.value, category: c === categoryAll.value || c === cPersonal ? undefined : c, owner: c === cPersonal ? undefined : '*', - isPublic: c === cPersonal ? undefined : IsPublic.public + visibility: c === cPersonal ? undefined : Visibility.Public }) }, { @@ -191,7 +191,7 @@ function handleSelectCategory(c: Category) { const selected = shallowReactive([]) async function addAssetToProject(asset: AssetData) { - switch (asset.assetType) { + switch (asset.type) { case AssetType.Sprite: { const sprite = await asset2Sprite(asset) props.project.addSprite(sprite) diff --git a/spx-gui/src/components/community/user/UserItem.vue b/spx-gui/src/components/community/user/UserItem.vue index 41f2853c5..561b1f6ab 100644 --- a/spx-gui/src/components/community/user/UserItem.vue +++ b/spx-gui/src/components/community/user/UserItem.vue @@ -18,7 +18,7 @@ const userRoute = computed(() => getUserPageRoute(props.user.username))
diff --git a/spx-gui/src/components/community/user/header/UserHeader.vue b/spx-gui/src/components/community/user/header/UserHeader.vue index 0d3f08ce6..5ad01bb03 100644 --- a/spx-gui/src/components/community/user/header/UserHeader.vue +++ b/spx-gui/src/components/community/user/header/UserHeader.vue @@ -17,7 +17,7 @@ defineProps<{

{{ user.displayName }} - +

{{ user.description }}

diff --git a/spx-gui/src/components/editor/navbar/EditorNavbar.vue b/spx-gui/src/components/editor/navbar/EditorNavbar.vue index 4acbd5d38..419fad53f 100644 --- a/spx-gui/src/components/editor/navbar/EditorNavbar.vue +++ b/spx-gui/src/components/editor/navbar/EditorNavbar.vue @@ -33,7 +33,7 @@ {{ $t({ en: 'Share project', zh: '分享项目' }) }} @@ -100,7 +100,7 @@ import { useI18n, type LocaleMessage } from '@/utils/i18n' import { useNetwork } from '@/utils/network' import { selectFile } from '@/utils/file' import { AutoSaveToCloudState, type Project } from '@/models/project' -import { IsPublic } from '@/apis/common' +import { Visibility } from '@/apis/common' import { useRemoveProject, useShareProject, useStopSharingProject } from '@/components/project' import { useLoadFromScratchModal } from '@/components/asset' import NavbarWrapper from '@/components/navbar/NavbarWrapper.vue' diff --git a/spx-gui/src/components/project/ProjectCreateModal.vue b/spx-gui/src/components/project/ProjectCreateModal.vue index b0c9f2bb0..1cb90aff8 100644 --- a/spx-gui/src/components/project/ProjectCreateModal.vue +++ b/spx-gui/src/components/project/ProjectCreateModal.vue @@ -44,7 +44,7 @@ import { useForm, type FormValidationResult } from '@/components/ui' -import { type ProjectData, getProject, addProject, IsPublic } from '@/apis/project' +import { type ProjectData, getProject, addProject, Visibility } from '@/apis/project' import { useI18n } from '@/utils/i18n' import { useMessageHandle } from '@/utils/exception' import { useUserStore } from '@/stores/user' @@ -103,7 +103,7 @@ const handleSubmit = useMessageHandle( const { fileCollection } = await saveFiles(files) const projectData = await addProject({ name: form.value.name, - isPublic: IsPublic.personal, + visibility: Visibility.Private, files: fileCollection }) emit('resolved', projectData) diff --git a/spx-gui/src/components/project/ProjectItem.vue b/spx-gui/src/components/project/ProjectItem.vue index bacdb300f..79e512787 100644 --- a/spx-gui/src/components/project/ProjectItem.vue +++ b/spx-gui/src/components/project/ProjectItem.vue @@ -24,7 +24,7 @@
{{ project.name }}